Compare commits
48
Commits
6ebd45f2cb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36fddc23b | ||
|
|
e04aa14b84 | ||
|
|
3c27fae626 | ||
|
|
33eeb3242a | ||
|
|
ae16e43013 | ||
|
|
7a55b84b81 | ||
|
|
a1ac677b3a | ||
|
|
b00465439e | ||
|
|
9af0e9e94e | ||
|
|
b745b6d460 | ||
|
|
fec5ece255 | ||
|
|
f4f2311ae4 | ||
|
|
57e0fdd600 | ||
|
|
b583858088 | ||
|
|
453ff1a5f6 | ||
|
|
68fcece743 | ||
|
|
de77066cb0 | ||
|
|
d23dd20237 | ||
|
|
8a4bd70ad4 | ||
|
|
d87321c7bf | ||
|
|
1a05eee46d | ||
|
|
ec9a7cf9c9 | ||
|
|
4f08ffae36 | ||
|
|
d7cb92bf81 | ||
|
|
871d7bba1a | ||
|
|
f13a1249af | ||
|
|
fdf1fbe4aa | ||
|
|
6df198321a | ||
|
|
4c8604d346 | ||
|
|
25a24772fc | ||
|
|
932dc3f1c1 | ||
|
|
5b26b9156d | ||
|
|
d12d7b91cb | ||
|
|
49459707cb | ||
|
|
ba7f942059 | ||
|
|
96105468d4 | ||
|
|
7a8007fff0 | ||
|
|
da851bd9e5 | ||
|
|
dae3ae3f7e | ||
|
|
b03d97dd9e | ||
|
|
8b8d01976d | ||
|
|
4764562bd4 | ||
|
|
0e1bc4a1fe | ||
|
|
3c9b906bd6 | ||
|
|
ef58922e13 | ||
|
|
06036b00d3 | ||
|
|
d33d88e76d | ||
|
|
5ef693990c |
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
|
||||
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- مسئولان اختیاری روی گروههای مشتری
|
||||
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
|
||||
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_CustomerGroupAssignees"
|
||||
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
|
||||
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "_CustomerGroupAssignees"
|
||||
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
|
||||
FOREIGN KEY ("B") REFERENCES "User"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -33,6 +33,17 @@ enum ActivityStatus {
|
||||
canceled
|
||||
}
|
||||
|
||||
enum ActivityCompletionMode {
|
||||
any
|
||||
all
|
||||
}
|
||||
|
||||
/// چه کسانی میتوانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
|
||||
enum ActivityEditPolicy {
|
||||
creator
|
||||
assignees
|
||||
}
|
||||
|
||||
enum ActivityPriority {
|
||||
low
|
||||
medium
|
||||
@@ -151,6 +162,10 @@ model Organization {
|
||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||
/// ماژولهای فعال منو برای این محیط
|
||||
enabledModules String[] @default([])
|
||||
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
|
||||
activityPriorities Json @default("{}")
|
||||
/// قالبهای آمادهٔ پیامک دستی ([{ id, name, body }, …])
|
||||
smsTemplates Json @default("[]")
|
||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||
priorArchiveEnabledAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
@@ -295,6 +310,7 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
@@ -565,6 +581,8 @@ model CustomerGroup {
|
||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
||||
people PersonGroupLink[]
|
||||
companies CompanyGroupLink[]
|
||||
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود میبینند
|
||||
assignees User[] @relation("CustomerGroupAssignees")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -883,6 +901,12 @@ model Activity {
|
||||
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک میشود
|
||||
reminderSentAt DateTime?
|
||||
completedAt DateTime?
|
||||
/// any = با انجام یکی بسته میشود؛ all = هر مسئول باید سهم خودش را بزند
|
||||
completionMode ActivityCompletionMode @default(any)
|
||||
/// مسئولانی که سهم خود را در حالت all ثبت کردهاند
|
||||
completedByIds String[] @default([])
|
||||
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
|
||||
editPolicy ActivityEditPolicy @default(creator)
|
||||
tags String[] @default([])
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
@@ -2447,8 +2471,11 @@ model FinancialPayroll {
|
||||
period String
|
||||
baseAmount Decimal @db.Decimal(18, 2)
|
||||
benefits Decimal @default(0) @db.Decimal(18, 2)
|
||||
benefitsReason String?
|
||||
bonus Decimal @default(0) @db.Decimal(18, 2)
|
||||
bonusReason String?
|
||||
deductions Decimal @default(0) @db.Decimal(18, 2)
|
||||
deductionsReason String?
|
||||
payable Decimal @db.Decimal(18, 2)
|
||||
dueDate DateTime
|
||||
notes String?
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityBoardDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityPrioritiesDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
} from './dto';
|
||||
@@ -139,6 +141,18 @@ export class ActivitiesController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('priorities')
|
||||
listPriorities(@CurrentUser() user: Actor) {
|
||||
return this.typesService.listPriorities(user.organizationId);
|
||||
}
|
||||
|
||||
@Put('priorities')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
|
||||
return this.typesService.updatePriorities(user.organizationId, dto.items);
|
||||
}
|
||||
|
||||
@Get('types')
|
||||
listTypes(@CurrentUser() user: Actor) {
|
||||
return this.typesService.list(user.organizationId);
|
||||
@@ -196,6 +210,7 @@ export class ActivitiesController {
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
);
|
||||
@@ -232,6 +247,7 @@ export class ActivitiesController {
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
@@ -265,6 +281,7 @@ export class ActivitiesController {
|
||||
title: query.title,
|
||||
customer: query.customer,
|
||||
due: query.due,
|
||||
priority: query.priority,
|
||||
boardId: query.boardId,
|
||||
},
|
||||
query.stageId ? { stageId: query.stageId } : undefined,
|
||||
@@ -438,8 +455,16 @@ export class ActivitiesController {
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
complete(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Query('force') force?: string,
|
||||
@Query('undo') undo?: string,
|
||||
) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user, {
|
||||
force: force === '1' || force === 'true',
|
||||
undo: undo === '1' || undo === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
|
||||
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
||||
>;
|
||||
|
||||
function assigneeIdList(row: {
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
}): string[] {
|
||||
const ids = row.assignees?.map((item) => item.id) ?? [];
|
||||
if (ids.length) return [...new Set(ids)];
|
||||
return row.assigneeId ? [row.assigneeId] : [];
|
||||
}
|
||||
|
||||
function canForceClose(
|
||||
actor: { userId: string; role: string },
|
||||
createdById: string | null | undefined,
|
||||
): boolean {
|
||||
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
|
||||
}
|
||||
|
||||
function canMutateActivity(
|
||||
actor: { userId: string; role: string },
|
||||
row: {
|
||||
createdById?: string | null;
|
||||
editPolicy?: 'creator' | 'assignees' | null;
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
},
|
||||
): boolean {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
if (row.createdById && actor.userId === row.createdById) return true;
|
||||
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
|
||||
return assigneeIdList(row).includes(actor.userId);
|
||||
}
|
||||
|
||||
/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */
|
||||
function canCollaborateActivity(
|
||||
actor: { userId: string; role: string },
|
||||
row: {
|
||||
createdById?: string | null;
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
},
|
||||
): boolean {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
if (row.createdById && actor.userId === row.createdById) return true;
|
||||
return assigneeIdList(row).includes(actor.userId);
|
||||
}
|
||||
|
||||
/** فقط dueAt / description → تعویق و ثبت نظر */
|
||||
function isCollaboratorPatch(dto: UpdateActivityRequest): boolean {
|
||||
const keys = Object.keys(dto).filter(
|
||||
(key) => (dto as Record<string, unknown>)[key] !== undefined,
|
||||
);
|
||||
return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description');
|
||||
}
|
||||
|
||||
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
|
||||
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
|
||||
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
|
||||
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
|
||||
}
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
|
||||
@@ -64,6 +123,7 @@ type ListFacets = {
|
||||
customer?: string;
|
||||
/** set = زمان شروع دارد، unset = ندارد */
|
||||
due?: 'set' | 'unset';
|
||||
priority?: ActivityDto['priority'];
|
||||
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
||||
boardId?: string;
|
||||
};
|
||||
@@ -82,6 +142,9 @@ type ActivityRow = {
|
||||
reminderNotifySms: boolean;
|
||||
reminderSentAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
completionMode?: 'any' | 'all';
|
||||
completedByIds?: string[];
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
importSource?: string;
|
||||
promotedFromPriorAt?: Date | null;
|
||||
tags: string[];
|
||||
@@ -186,22 +249,39 @@ export class ActivitiesService {
|
||||
return { stages, types };
|
||||
}
|
||||
|
||||
/** کارهای بازِ «من» که سهمشان ثبت شده، از میز کار شخص خارج میشوند. */
|
||||
private deskScope(
|
||||
actor: ActivityActor,
|
||||
desk?: 'mine' | 'team',
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (desk !== 'mine') return activityAccessWhere(actor);
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
completionMode: 'all',
|
||||
status: 'open',
|
||||
completedByIds: { has: actor.userId },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
desk?: 'mine' | 'team',
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: activityAccessWhere(actor);
|
||||
const scope = this.deskScope(actor, desk);
|
||||
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
@@ -534,8 +614,24 @@ export class ActivitiesService {
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, completedAt: true },
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
stage.kind === 'done' &&
|
||||
existing?.completionMode === 'all' &&
|
||||
assigneeIdList(existing).length > 1 &&
|
||||
!canForceClose(actor, existing.createdById)
|
||||
) {
|
||||
return this.complete(organizationId, id, actor);
|
||||
}
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -550,6 +646,9 @@ export class ActivitiesService {
|
||||
: stage.kind === 'open'
|
||||
? null
|
||||
: undefined,
|
||||
...(stage.kind === 'open' && existing?.status !== 'open'
|
||||
? { completedByIds: [] }
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
@@ -675,6 +774,9 @@ export class ActivitiesService {
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
|
||||
completedByIds: [],
|
||||
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
@@ -743,8 +845,31 @@ export class ActivitiesService {
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
|
||||
select: {
|
||||
status: true,
|
||||
type: true,
|
||||
typeDefId: true,
|
||||
fields: true,
|
||||
participantIds: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
editPolicy: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const collabOnly = isCollaboratorPatch(dto);
|
||||
if (collabOnly) {
|
||||
if (!canCollaborateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید');
|
||||
}
|
||||
} else if (!canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('boardId' in dto) {
|
||||
@@ -812,6 +937,7 @@ export class ActivitiesService {
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
let nextAssigneeIds: string[] | undefined;
|
||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||
const current = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
@@ -839,6 +965,7 @@ export class ActivitiesService {
|
||||
);
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
nextAssigneeIds = assigneeIds;
|
||||
} else {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
@@ -852,12 +979,44 @@ export class ActivitiesService {
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
nextAssigneeIds = assigneeId ? [assigneeId] : [];
|
||||
}
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
data.completedAt =
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
|
||||
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
|
||||
if (modeChanged) data.completionMode = nextMode;
|
||||
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
|
||||
data.editPolicy = dto.editPolicy;
|
||||
}
|
||||
const prevAssigneeIds = assigneeIdList(existing ?? {});
|
||||
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
|
||||
const explicitDone = dto.status === 'done';
|
||||
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
|
||||
const explicitCancel = dto.status === 'canceled';
|
||||
if (explicitDone) {
|
||||
data.status = 'done';
|
||||
data.completedAt = new Date();
|
||||
} else if (explicitReopen) {
|
||||
data.status = 'open';
|
||||
data.completedAt = null;
|
||||
data.completedByIds = [];
|
||||
} else if (explicitCancel) {
|
||||
data.status = 'canceled';
|
||||
} else if (nextMode === 'any' && modeChanged) {
|
||||
data.completedByIds = [];
|
||||
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
|
||||
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
|
||||
data.completedByIds = reconciled.completedByIds;
|
||||
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
|
||||
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
|
||||
if (reconciled.status === 'done' && existing?.status !== 'done') {
|
||||
data.status = 'done';
|
||||
data.completedAt = new Date();
|
||||
} else if (reconciled.status === 'open' && existing?.status === 'done') {
|
||||
data.status = 'open';
|
||||
data.completedAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.prisma.activity.update({
|
||||
@@ -881,7 +1040,7 @@ export class ActivitiesService {
|
||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
||||
);
|
||||
}
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
if (data.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
@@ -891,18 +1050,74 @@ export class ActivitiesService {
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
options: { force?: boolean; undo?: boolean } = {},
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
select: {
|
||||
status: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
if (existing.status === 'canceled') {
|
||||
throw new BadRequestException('فعالیت لغو شده است');
|
||||
}
|
||||
|
||||
const assignees = assigneeIdList(existing);
|
||||
const allowedForce = canForceClose(actor, existing.createdById);
|
||||
if (options.force && !allowedForce) {
|
||||
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
|
||||
}
|
||||
|
||||
if (options.undo) {
|
||||
if (existing.completionMode !== 'all' || existing.status !== 'open') {
|
||||
throw new BadRequestException('سهم انجامشده را فقط تا وقتی فعالیت باز است میتوان لغو کرد');
|
||||
}
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
const personal =
|
||||
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
|
||||
if (!personal) {
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
include,
|
||||
});
|
||||
if (existing.status !== 'done') await this.fireDone(organizationId, row);
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
if (!assignees.includes(actor.userId)) {
|
||||
throw new ForbiddenException('فقط مسئول این فعالیت میتواند سهم خودش را ثبت کند');
|
||||
}
|
||||
const reconciled = reconcileAllShares(assignees, [
|
||||
...existing.completedByIds,
|
||||
actor.userId,
|
||||
]);
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
data: {
|
||||
completedByIds: reconciled.completedByIds,
|
||||
status: reconciled.status,
|
||||
completedAt: reconciled.status === 'done' ? new Date() : null,
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (existing?.status !== 'done') {
|
||||
if (reconciled.status === 'done' && existing.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
@@ -946,6 +1161,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing || !canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
|
||||
}
|
||||
const images = await this.prisma.activityImage.findMany({
|
||||
where: { activityId: id, organizationId },
|
||||
select: { storageKey: true, sizeBytes: true },
|
||||
@@ -966,6 +1193,19 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityImageDto> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
// پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator
|
||||
if (!gate || !canCollaborateActivity(actor, gate)) {
|
||||
throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید');
|
||||
}
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
@@ -1060,10 +1300,27 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||
const canMutate = !!gate && canMutateActivity(actor, gate);
|
||||
const canRemoveOwn =
|
||||
!!gate &&
|
||||
canCollaborateActivity(actor, gate) &&
|
||||
row.uploadedById === actor.userId;
|
||||
if (!canMutate && !canRemoveOwn) {
|
||||
throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید');
|
||||
}
|
||||
await this.files.remove(row.storageKey);
|
||||
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||
@@ -1108,25 +1365,18 @@ export class ActivitiesService {
|
||||
typeRows: TypeRow[] = [],
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: activityAccessWhere(actor);
|
||||
const scope = this.deskScope(actor, desk);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const facetTitle = facets?.title?.trim();
|
||||
const facetCustomer = facets?.customer?.trim();
|
||||
const facetPriority = facets?.priority;
|
||||
const andParts: Prisma.ActivityWhereInput[] = [
|
||||
scope,
|
||||
...(facets?.type ? [{ type: facets.type }] : []),
|
||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||
...(facets?.status ? [{ status: facets.status }] : []),
|
||||
...(facetPriority ? [{ priority: facetPriority }] : []),
|
||||
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
|
||||
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
|
||||
...(facetAssignee
|
||||
@@ -1418,6 +1668,9 @@ export class ActivitiesService {
|
||||
reminderNotifySms: row.reminderNotifySms,
|
||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
completionMode: row.completionMode ?? 'any',
|
||||
completedByIds: row.completedByIds ?? [],
|
||||
editPolicy: row.editPolicy ?? 'creator',
|
||||
importSource: row.importSource ?? 'manual',
|
||||
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
||||
tags: row.tags ?? [],
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
ActivityPriority,
|
||||
ActivityPrioritySetting,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
@@ -17,8 +19,11 @@ import type {
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_PRIORITY_KEYS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
parseActivityPrioritySettings,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -619,6 +624,43 @@ export class ActivityTypesService {
|
||||
: 'green';
|
||||
}
|
||||
|
||||
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
|
||||
const row = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { activityPriorities: true },
|
||||
});
|
||||
return parseActivityPrioritySettings(row?.activityPriorities);
|
||||
}
|
||||
|
||||
async updatePriorities(
|
||||
organizationId: string,
|
||||
items: Array<{ key: ActivityPriority; label: string; color: string }>,
|
||||
): Promise<ActivityPrioritySetting[]> {
|
||||
const byKey = new Map(items.map((i) => [i.key, i]));
|
||||
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
|
||||
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
|
||||
}
|
||||
const hex = /^#[0-9a-fA-F]{6}$/;
|
||||
const stored: Record<string, { label: string; color: string }> = {};
|
||||
for (const key of ACTIVITY_PRIORITY_KEYS) {
|
||||
const item = byKey.get(key)!;
|
||||
const label = item.label.trim().slice(0, 40);
|
||||
const color = item.color.trim();
|
||||
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
|
||||
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
|
||||
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
|
||||
// فقط تفاوت با پیشفرض را نگه میداریم تا JSON جمعوجور بماند
|
||||
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
|
||||
stored[key] = { label, color };
|
||||
}
|
||||
}
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { activityPriorities: stored as Prisma.InputJsonValue },
|
||||
});
|
||||
return parseActivityPrioritySettings(stored);
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||
@@ -91,6 +93,11 @@ export class ListActivitiesQueryDto {
|
||||
@IsString()
|
||||
typeDefId?: string;
|
||||
|
||||
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
|
||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -384,6 +391,14 @@ export class CreateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -484,4 +499,35 @@ export class UpdateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
|
||||
|
||||
export class ActivityPriorityItemDto {
|
||||
@IsIn(PRIORITY_KEYS)
|
||||
key!: (typeof PRIORITY_KEYS)[number];
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^#[0-9a-fA-F]{6}$/)
|
||||
color!: string;
|
||||
}
|
||||
|
||||
export class UpdateActivityPrioritiesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ActivityPriorityItemDto)
|
||||
items!: ActivityPriorityItemDto[];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import type {
|
||||
StorageUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||
import type {} from 'multer';
|
||||
@@ -848,6 +852,9 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
@@ -879,9 +886,10 @@ export class AuthService {
|
||||
storage,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: []) as AuthUserDto['permissions'],
|
||||
permissions: permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
) as AuthUserDto['permissions'],
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
const peers = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: { in: memberships.map((m) => m.teamId) },
|
||||
team: { organizationId },
|
||||
user: {
|
||||
memberships: { some: { organizationId, isActive: true } },
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
defaultEnabledModules,
|
||||
isPermanentBillingPlan,
|
||||
isOrgModuleKey,
|
||||
modulesForProductPlan,
|
||||
PRODUCT_PLAN_LIMITS,
|
||||
type ProductPlanCode,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
@@ -414,7 +417,37 @@ export class BillingService {
|
||||
}
|
||||
|
||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||
return defaultEnabledModules();
|
||||
return modulesForProductPlan('core');
|
||||
}
|
||||
|
||||
/** سطح امکانات را روی همهٔ محیطهای مالک میگذارد و سقف هسته/تیم/سازمان را هم تنظیم میکند. */
|
||||
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
|
||||
const modules = modulesForProductPlan(code);
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
|
||||
for (const id of orgIds) {
|
||||
await this.setOrgModules(id, modules);
|
||||
}
|
||||
const limits = PRODUCT_PLAN_LIMITS[code];
|
||||
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
|
||||
const account = await this.getByOwnerUserId(owner.userId);
|
||||
if (account) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
|
||||
...(limits.maxOrganizations != null
|
||||
? { maxOrganizations: limits.maxOrganizations }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
async countExpiringSoon() {
|
||||
|
||||
+138
-109
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
|
||||
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
|
||||
if (!facets.length) return null;
|
||||
return facets.length === 1 ? facets[0]! : { OR: facets };
|
||||
}
|
||||
|
||||
export function buildContactRuleWhere(
|
||||
rule: DataAccessRule,
|
||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||
): Prisma.PersonWhereInput | null {
|
||||
const and: Prisma.PersonWhereInput[] = [];
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
const facets: Prisma.PersonWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
if (!ids.length) return null;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
const ids = ownerIdList(rule, ctx);
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
}
|
||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (personViaGroupAssignee)
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (rule.moods?.length) {
|
||||
and.push({ mood: { in: rule.moods } });
|
||||
facets.push({ mood: { in: rule.moods } });
|
||||
}
|
||||
if (!and.length) return null;
|
||||
return and.length === 1 ? and[0]! : { AND: and };
|
||||
return unionFacets(facets);
|
||||
}
|
||||
|
||||
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
||||
@@ -135,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
||||
return parts.length === 1 ? parts[0]! : { OR: parts };
|
||||
}
|
||||
|
||||
/** اعضای گروههایی که کاربر مسئولشان است */
|
||||
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
|
||||
return {
|
||||
groupLinks: {
|
||||
some: { group: { assignees: { some: { id: userId } } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
|
||||
return {
|
||||
groupLinks: {
|
||||
some: { group: { assignees: { some: { id: userId } } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function customersPersonWhere(
|
||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
||||
): Prisma.PersonWhereInput {
|
||||
@@ -149,6 +170,8 @@ export function customersPersonWhere(
|
||||
});
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
|
||||
parts.push(personViaGroupAssignee(actor.userId));
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.CompanyWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.CompanyWhereInput[] = [];
|
||||
// حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
|
||||
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
|
||||
const facets: Prisma.CompanyWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
if (rule.groupIds?.length) {
|
||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||
}
|
||||
}
|
||||
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (companyViaGroupAssignee)
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||
});
|
||||
}
|
||||
if (
|
||||
rule.moods?.length &&
|
||||
!rule.owners?.length &&
|
||||
!rule.groupIds?.length &&
|
||||
!rule.sources?.length
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
parts.push(companyViaGroupAssignee(actor.userId));
|
||||
return orParts(parts);
|
||||
}
|
||||
|
||||
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.LeadWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.LeadWhereInput[] = [];
|
||||
const facets: Prisma.LeadWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
OR: rule.sources.map((s) => ({
|
||||
source: { equals: s, mode: 'insensitive' as const },
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.DealWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.DealWhereInput[] = [];
|
||||
const facets: Prisma.DealWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.pipelineIds?.length) {
|
||||
and.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
facets.push({ pipelineId: { in: rule.pipelineIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -266,32 +285,33 @@ export function activitiesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ActivityWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ActivityWhereInput[] = [];
|
||||
const facets: Prisma.ActivityWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
{ participantIds: { hasSome: ids } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
facets.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ typeDefId: { in: rule.typeIds } });
|
||||
facets.push({ typeDefId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -310,34 +330,35 @@ export function notesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
||||
const parts: Prisma.NoteWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.NoteWhereInput[] = [];
|
||||
const facets: Prisma.NoteWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ authorId: { in: ids } },
|
||||
{
|
||||
deal: {
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
});
|
||||
{ person: { ownerId: { in: ids } } },
|
||||
{ company: { ownerId: { in: ids } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({ noteType: { in: rule.sources } });
|
||||
facets.push({ noteType: { in: rule.sources } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return {
|
||||
AND: [personalGate, orParts(parts)],
|
||||
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.TicketWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.TicketWhereInput[] = [];
|
||||
const facets: Prisma.TicketWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
and.push({
|
||||
facets.push({
|
||||
status: {
|
||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.ProjectCardWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.ProjectCardWhereInput[] = [];
|
||||
const facets: Prisma.ProjectCardWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
if (ids.length) {
|
||||
facets.push({
|
||||
OR: [
|
||||
{ ownerId: { in: ids } },
|
||||
{ assignees: { some: { id: { in: ids } } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
and.push({ boardId: { in: rule.boardIds } });
|
||||
facets.push({ boardId: { in: rule.boardIds } });
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
and.push({ stageId: { in: rule.stageIds } });
|
||||
facets.push({ stageId: { in: rule.stageIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
|
||||
if (!sectionHasDataAccessLimits(section)) return {};
|
||||
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
||||
for (const rule of section.rules ?? []) {
|
||||
const and: Prisma.RoutineTaskWhereInput[] = [];
|
||||
const facets: Prisma.RoutineTaskWhereInput[] = [];
|
||||
if (rule.owners?.length) {
|
||||
const ids = ownerIdList(rule, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (!ids.length) continue;
|
||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
if (ids.length) {
|
||||
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||
}
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
and.push({ categoryId: { in: rule.typeIds } });
|
||||
facets.push({ categoryId: { in: rule.typeIds } });
|
||||
}
|
||||
if (!and.length) continue;
|
||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
||||
const w = unionFacets(facets);
|
||||
if (w) parts.push(w);
|
||||
}
|
||||
return orParts(parts);
|
||||
}
|
||||
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
|
||||
if (rule.owners?.length) {
|
||||
sawOwnerRule = true;
|
||||
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
||||
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
|
||||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
|
||||
rule.typeIds?.length) {
|
||||
} else if (
|
||||
rule.sources?.length ||
|
||||
rule.moods?.length ||
|
||||
rule.stageIds?.length ||
|
||||
rule.pipelineIds?.length ||
|
||||
rule.boardIds?.length ||
|
||||
rule.typeIds?.length
|
||||
) {
|
||||
sawNonOwnerRule = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
SectionDataAccess,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
activitiesDeepWhere,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notesDeepWhere,
|
||||
projectsDeepWhere,
|
||||
routinesDeepWhere,
|
||||
sectionOwnerIdUnion,
|
||||
ticketsDeepWhere,
|
||||
} from './data-access';
|
||||
|
||||
@@ -60,6 +61,65 @@ export type Actor = {
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
type DeepAccessActor = ScopedActor &
|
||||
Partial<
|
||||
Pick<
|
||||
Actor,
|
||||
| 'customersAccess'
|
||||
| 'leadsAccess'
|
||||
| 'salesAccess'
|
||||
| 'activitiesAccess'
|
||||
| 'notesAccess'
|
||||
| 'routinesAccess'
|
||||
| 'projectsAccess'
|
||||
| 'ticketsAccess'
|
||||
| 'teamPeerIds'
|
||||
>
|
||||
>;
|
||||
|
||||
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
|
||||
switch (scope) {
|
||||
case 'contacts':
|
||||
return actor.customersAccess;
|
||||
case 'leads':
|
||||
return actor.leadsAccess;
|
||||
case 'deals':
|
||||
return actor.salesAccess;
|
||||
case 'activities':
|
||||
return actor.activitiesAccess;
|
||||
case 'notes':
|
||||
return actor.notesAccess;
|
||||
case 'routines':
|
||||
return actor.routinesAccess;
|
||||
case 'projects':
|
||||
return actor.projectsAccess;
|
||||
case 'tickets':
|
||||
return actor.ticketsAccess;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسههای مالک از قانون عمق داده.
|
||||
* legacy = بخش عمیق روی این Actor نیست.
|
||||
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
|
||||
*/
|
||||
function deepOwnerIds(
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope,
|
||||
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
|
||||
const section = sectionForScope(actor, scope);
|
||||
if (!section) return { kind: 'legacy' };
|
||||
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
|
||||
const ids = sectionOwnerIdUnion(section, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (ids === null) return { kind: 'open' };
|
||||
return { kind: 'ids', ids };
|
||||
}
|
||||
|
||||
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
||||
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
||||
const s = actor.scopes?.[scope];
|
||||
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
|
||||
|
||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||
export function ownerWhere(
|
||||
actor: ScopedActor,
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope = 'contacts',
|
||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
||||
const deep = deepOwnerIds(actor, scope);
|
||||
if (deep.kind === 'open') return {};
|
||||
if (deep.kind === 'ids') {
|
||||
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
|
||||
if (!deep.ids.length) return { ownerId: '__no_access__' };
|
||||
return { ownerId: { in: deep.ids } };
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, scope);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
|
||||
const deep = deepOwnerIds(actor, 'activities');
|
||||
const ids =
|
||||
deep.kind === 'open'
|
||||
? null
|
||||
: deep.kind === 'ids'
|
||||
? deep.ids
|
||||
: scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
if (!ids.length) return { assigneeId: '__no_access__' };
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
|
||||
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
|
||||
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
|
||||
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
|
||||
return {
|
||||
year: today.jy,
|
||||
month: today.jm,
|
||||
monthName: jalaliMonthName(today.jm),
|
||||
today: today.jd,
|
||||
|
||||
@@ -112,9 +112,13 @@ export class CompaniesService {
|
||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||
];
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
...and,
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
@@ -165,6 +169,7 @@ export class CompaniesService {
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: 0,
|
||||
companyCount: countMap.get(g.id) ?? 0,
|
||||
assignees: [],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { PublicApiModule } from '../public-api/public-api.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
PublicApiModule,
|
||||
BehaviorModule,
|
||||
CustomerGroupsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
|
||||
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
|
||||
@MinLength(1)
|
||||
@MaxLength(500)
|
||||
text!: string;
|
||||
|
||||
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class BulkDeletePeopleDto {
|
||||
|
||||
@@ -139,6 +139,7 @@ export class PeopleController {
|
||||
}
|
||||
|
||||
@Get('birthdays')
|
||||
@RequirePermission('customers', { reads: true })
|
||||
birthdays(@CurrentUser() user: Actor) {
|
||||
return this.peopleService.birthdaysThisMonth(user);
|
||||
}
|
||||
@@ -171,7 +172,7 @@ export class PeopleController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BirthdaySmsDto,
|
||||
) {
|
||||
return this.peopleService.sendBirthdaySms(user, id, dto.text);
|
||||
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FILTERED_IDS_MAX,
|
||||
PERSON_MOODS,
|
||||
applyPersonGenderTitle,
|
||||
canViewAllRecords,
|
||||
splitPersonHonorific,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { WebhooksService } from '../public-api/webhooks.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
@@ -157,6 +159,7 @@ export class PeopleService implements OnModuleInit {
|
||||
private readonly outboundSms: OutboundSmsLogService,
|
||||
private readonly behaviorAccess: BehaviorAccessService,
|
||||
private readonly groups: CustomerGroupsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
private personListWhere(
|
||||
@@ -216,13 +219,27 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
const disc = discFilter(opts?.disc);
|
||||
if (disc) and.push(disc);
|
||||
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
|
||||
const facets: Prisma.PersonWhereInput[] = [];
|
||||
if (groupIds.length) {
|
||||
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
}
|
||||
if (moods.length) {
|
||||
facets.push({ mood: { in: moods } });
|
||||
}
|
||||
if (sources.length) {
|
||||
facets.push({ importSource: { in: sources } });
|
||||
}
|
||||
if (facets.length === 1) and.push(facets[0]!);
|
||||
else if (facets.length > 1) and.push({ OR: facets });
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
...(moods.length ? { mood: { in: moods } } : {}),
|
||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -345,6 +362,7 @@ export class PeopleService implements OnModuleInit {
|
||||
fromTahesab: Boolean(g.tahesabGid),
|
||||
personCount: countMap.get(g.id) ?? 0,
|
||||
companyCount: 0,
|
||||
assignees: [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -680,8 +698,8 @@ export class PeopleService implements OnModuleInit {
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
ownerId: actor.userId,
|
||||
birthDate: { not: null },
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -728,13 +746,17 @@ export class PeopleService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
async sendBirthdaySms(actor: Actor, personId: string, text: string) {
|
||||
async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
|
||||
const body = text.trim();
|
||||
if (!body || body.length > 500) {
|
||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId: actor.organizationId },
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
@@ -744,11 +766,29 @@ export class PeopleService implements OnModuleInit {
|
||||
ownerId: true,
|
||||
birthDate: true,
|
||||
company: { select: { name: true } },
|
||||
groupLinks: {
|
||||
select: {
|
||||
group: {
|
||||
select: {
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
||||
if (person.ownerId !== actor.userId) {
|
||||
throw new ForbiddenException('فقط مسئول این مشتری میتواند پیامک تبریک بفرستد.');
|
||||
const assigneeIds = new Set(
|
||||
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
|
||||
);
|
||||
const canSend =
|
||||
canViewAllRecords(actor.role) ||
|
||||
person.ownerId === actor.userId ||
|
||||
assigneeIds.has(actor.userId);
|
||||
if (!canSend) {
|
||||
throw new ForbiddenException(
|
||||
'فقط مالک مشتری یا مسئول گروه او میتواند پیامک تبریک بفرستد.',
|
||||
);
|
||||
}
|
||||
if (!person.birthDate) {
|
||||
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
|
||||
@@ -770,6 +810,32 @@ export class PeopleService implements OnModuleInit {
|
||||
const mobile = person.mobile || person.phone;
|
||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
||||
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
|
||||
if (!force) {
|
||||
const prior = await this.prisma.outboundSmsLog.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
personId: person.id,
|
||||
status: 'sent',
|
||||
sourceId: birthdayKey,
|
||||
NOT: { actorUserId: actor.userId },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { actorUserId: true },
|
||||
});
|
||||
if (prior) {
|
||||
const priorUser = prior.actorUserId
|
||||
? await this.prisma.user.findFirst({
|
||||
where: { id: prior.actorUserId },
|
||||
select: { fullName: true },
|
||||
})
|
||||
: null;
|
||||
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
|
||||
throw new ConflictException(
|
||||
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا میخواهید دوباره بفرستید؟`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = renderSmsText(body, {
|
||||
name: fullName,
|
||||
phone: mobile,
|
||||
@@ -789,6 +855,7 @@ export class PeopleService implements OnModuleInit {
|
||||
mobile,
|
||||
body: message,
|
||||
source: 'manual',
|
||||
sourceId: birthdayKey,
|
||||
status: result.ok ? 'sent' : 'failed',
|
||||
error: result.ok ? null : result.reason,
|
||||
actorUserId: actor.userId,
|
||||
@@ -802,6 +869,28 @@ export class PeopleService implements OnModuleInit {
|
||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
const senderName = (
|
||||
await this.prisma.user.findFirst({
|
||||
where: { id: actor.userId },
|
||||
select: { fullName: true },
|
||||
})
|
||||
)?.fullName?.trim() || 'یکی از مسئولان';
|
||||
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
|
||||
await Promise.all(
|
||||
peers.map((userId) =>
|
||||
this.notifications.create({
|
||||
organizationId: actor.organizationId,
|
||||
userId,
|
||||
type: 'birthday_sms_sent',
|
||||
title: 'پیامک تبریک تولد ارسال شد',
|
||||
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است میتوانید دوباره بفرستید.`,
|
||||
link: `/contacts/people/${person.id}`,
|
||||
entityType: 'person',
|
||||
entityId: person.id,
|
||||
dedupe: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||
color?: CustomerGroupColor;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
class UpdateCustomerGroupDto {
|
||||
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
@Controller('customer-groups')
|
||||
|
||||
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
|
||||
include: {
|
||||
_count: { select: { people: true, companies: true } },
|
||||
parent: { select: { name: true } },
|
||||
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
parentId: r.parentId,
|
||||
parentName: r.parent?.name ?? null,
|
||||
}));
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: { name: string; color?: CustomerGroupColor },
|
||||
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
|
||||
): Promise<CustomerGroupDto> {
|
||||
const name = this.cleanName(dto.name);
|
||||
const assigneeIds =
|
||||
dto.assigneeIds !== undefined
|
||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||
: [];
|
||||
const max = await this.prisma.customerGroup.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
|
||||
name,
|
||||
color: this.cleanColor(dto.color),
|
||||
position: (max._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
||||
dto: {
|
||||
name?: string;
|
||||
color?: CustomerGroupColor;
|
||||
parentId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
},
|
||||
): Promise<CustomerGroupDto> {
|
||||
await this.require(organizationId, id);
|
||||
const parentId =
|
||||
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
|
||||
if (parentId !== undefined) {
|
||||
await this.assertParent(organizationId, id, parentId);
|
||||
}
|
||||
const assigneeIds =
|
||||
dto.assigneeIds !== undefined
|
||||
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||
: undefined;
|
||||
try {
|
||||
await this.prisma.customerGroup.update({
|
||||
where: { id },
|
||||
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
|
||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(assigneeIds !== undefined
|
||||
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
|
||||
return link?.group.name ?? null;
|
||||
}
|
||||
|
||||
private toDto(r: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
position: number;
|
||||
tahesabGid: string | null;
|
||||
parentId: string | null;
|
||||
parent?: { name: string } | null;
|
||||
_count: { people: number; companies: number };
|
||||
assignees: Array<{ id: string; fullName: string }>;
|
||||
}): CustomerGroupDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
fromTahesab: Boolean(r.tahesabGid),
|
||||
personCount: r._count.people,
|
||||
companyCount: r._count.companies,
|
||||
parentId: r.parentId,
|
||||
parentName: r.parent?.name ?? null,
|
||||
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
|
||||
};
|
||||
}
|
||||
|
||||
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
|
||||
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
|
||||
if (!unique.length) return [];
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: unique },
|
||||
isActive: true,
|
||||
memberships: { some: { organizationId, isActive: true } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (rows.length !== unique.length) {
|
||||
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
private async require(organizationId: string, id: string) {
|
||||
const row = await this.prisma.customerGroup.findFirst({
|
||||
where: { id, organizationId },
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import {
|
||||
pickAddress,
|
||||
pickCity,
|
||||
pickNationalId,
|
||||
pickRasteh,
|
||||
tahesabProfilePatch,
|
||||
} from '../moshtari-profile';
|
||||
import {
|
||||
assertFactorCode,
|
||||
buildFactorCode,
|
||||
@@ -116,12 +123,52 @@ function testSanadBuilders() {
|
||||
assert(bank[12] === fc, 'bank factor index');
|
||||
}
|
||||
|
||||
function testMoshtariProfile() {
|
||||
const row = {
|
||||
CodeMelli: '00112312311',
|
||||
Address: 'خیابان ولیعصر',
|
||||
City: 'تهران',
|
||||
Rasteh: 2,
|
||||
BDate: null,
|
||||
};
|
||||
assert(pickNationalId(row) === '00112312311', 'national id');
|
||||
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
|
||||
assert(pickCity(row) === 'تهران', 'city');
|
||||
assert(pickRasteh(row) === 2, 'rasteh');
|
||||
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
|
||||
|
||||
const patch = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: { keep: true },
|
||||
});
|
||||
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
|
||||
const fields = patch.customFields as Record<string, unknown>;
|
||||
assert(fields.nationalId === '00112312311', 'cf national');
|
||||
assert(fields.city === 'تهران', 'cf city');
|
||||
assert(fields.rasteh === 2, 'cf rasteh');
|
||||
assert(fields.keep === true, 'cf merge');
|
||||
|
||||
const emptyKeep = tahesabProfilePatch(
|
||||
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
|
||||
{
|
||||
birthDate: new Date('2000-01-01'),
|
||||
addresses: ['قدیمی'],
|
||||
customFields: { nationalId: '111' },
|
||||
},
|
||||
);
|
||||
assert(emptyKeep.addresses == null, 'empty does not wipe address');
|
||||
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
|
||||
assert(emptyKeep.birthDate == null, 'existing birth kept');
|
||||
}
|
||||
|
||||
function main() {
|
||||
testCryptoRoundtrip();
|
||||
testFactorCode();
|
||||
testMobile();
|
||||
testMandehNormalize();
|
||||
testSanadBuilders();
|
||||
testMoshtariProfile();
|
||||
console.log('tahesab unit checks: OK');
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** اجرای دستی همگامسازی همهٔ مشتریان (وارد + بهروز کردن لینکشدهها) */
|
||||
export class SyncAllCustomersDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
}
|
||||
|
||||
export class ExportPeopleToTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
|
||||
export function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function trimStr(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** کد ملی از پاسخ لیست مشتری تهحساب */
|
||||
export function pickNationalId(row: Record<string, unknown>): string | null {
|
||||
return (
|
||||
trimStr(row.CodeMelli) ??
|
||||
trimStr(row.CodeMeli) ??
|
||||
trimStr(row.codeMeli) ??
|
||||
trimStr(row.NationalId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function pickAddress(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
|
||||
}
|
||||
|
||||
export function pickCity(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
|
||||
}
|
||||
|
||||
/** رسته حساب در تهحساب (عدد یا رشته) */
|
||||
export function pickRasteh(row: Record<string, unknown>): string | number | null {
|
||||
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) && String(n) === s ? n : s;
|
||||
}
|
||||
|
||||
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function asAddressList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((a) => (typeof a === 'string' ? a.trim() : ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلدهای پروفایل قابلدریافت از DoListMoshtari را روی شخص مینشاند.
|
||||
* مقدار خالی تهحساب، دادهٔ موجود CRM را پاک نمیکند.
|
||||
*/
|
||||
export function tahesabProfilePatch(
|
||||
row: Record<string, unknown>,
|
||||
existing: {
|
||||
birthDate: Date | null;
|
||||
addresses: unknown;
|
||||
customFields: unknown;
|
||||
},
|
||||
): Prisma.PersonUpdateInput {
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
const birthDate = pickBirthDate(row);
|
||||
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
|
||||
|
||||
const address = pickAddress(row);
|
||||
const city = pickCity(row);
|
||||
if (address) {
|
||||
const line = city ? `${address}، ${city}` : address;
|
||||
data.addresses = [line] as Prisma.InputJsonValue;
|
||||
} else if (city && asAddressList(existing.addresses).length === 0) {
|
||||
data.addresses = [city] as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
const fields = asCustomFieldRecord(existing.customFields);
|
||||
let fieldsChanged = false;
|
||||
const nationalId = pickNationalId(row);
|
||||
if (nationalId) {
|
||||
fields.nationalId = nationalId;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (city) {
|
||||
fields.city = city;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (address) {
|
||||
fields.address = address;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
const rasteh = pickRasteh(row);
|
||||
if (rasteh != null) {
|
||||
fields.rasteh = rasteh;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
|
||||
return data;
|
||||
}
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
SearchTahesabDto,
|
||||
SyncGroupsPageDto,
|
||||
} from './dto';
|
||||
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { formatJalaliDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
|
||||
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
|
||||
}
|
||||
|
||||
function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function firstAddress(raw: unknown): string | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
||||
@@ -438,6 +428,77 @@ export class TahesabPersonLinkService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* کدهایی که لینک ندارند ولی شمارهشان مال مشتریِ از قبل لینکشدهٔ همین اتصال است.
|
||||
* یک شخص فقط یک کد تهحساب دارد؛ این کدها مشتری تازه نیستند.
|
||||
*/
|
||||
private async duplicateOwners(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
rows: Array<{ moshtariCode: number | null; tel: string }>,
|
||||
) {
|
||||
const pending = rows.filter(
|
||||
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
|
||||
);
|
||||
const keys = [
|
||||
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
|
||||
];
|
||||
const out = new Map<
|
||||
number,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
if (!keys.length) return out;
|
||||
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
accountingLinks: { some: { connectionId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
accountingLinks: {
|
||||
where: { connectionId },
|
||||
select: { moshtariCode: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const byKey = new Map<
|
||||
string,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
for (const person of people) {
|
||||
const link = person.accountingLinks[0];
|
||||
if (!link) continue;
|
||||
const info = {
|
||||
moshtariCode: link.moshtariCode,
|
||||
personId: person.id,
|
||||
personName: `${person.firstName} ${person.lastName}`.trim(),
|
||||
};
|
||||
for (const key of [
|
||||
...phoneIdentityKeys(person.mobile),
|
||||
...phoneIdentityKeys(person.phone),
|
||||
]) {
|
||||
if (!byKey.has(key)) byKey.set(key, info);
|
||||
}
|
||||
}
|
||||
for (const row of pending) {
|
||||
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
|
||||
const hit = byKey.get(key);
|
||||
if (hit && hit.moshtariCode !== row.moshtariCode) {
|
||||
out.set(row.moshtariCode, hit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async search(organizationId: string, dto: SearchTahesabDto) {
|
||||
const { api } = await this.connections.getApi(
|
||||
organizationId,
|
||||
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
|
||||
|
||||
const results = mapped.map((row) => {
|
||||
const linked = mapped.map((row) => {
|
||||
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
|
||||
return {
|
||||
...row,
|
||||
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const duplicates = await this.duplicateOwners(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linked.filter((row) => !row.alreadyLinked),
|
||||
);
|
||||
const results = linked.map((row) => {
|
||||
if (row.alreadyLinked || row.moshtariCode == null) return row;
|
||||
const dup = duplicates.get(row.moshtariCode);
|
||||
if (!dup) return row;
|
||||
return {
|
||||
...row,
|
||||
duplicateOfCode: dup.moshtariCode,
|
||||
duplicatePersonId: dup.personId,
|
||||
duplicatePersonName: dup.personName,
|
||||
};
|
||||
});
|
||||
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
let preserveExistingLink = false;
|
||||
if (personId) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const prevFirst = existing.firstName;
|
||||
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
...tahesabProfilePatch(row, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
||||
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمیخورند
|
||||
// تا کد قبلی جابهجا نشود و این کد هم مشتری تکراری نسازد.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
|
||||
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId: byMobile.id, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
return {
|
||||
personId: byMobile.id,
|
||||
link: this.linkDto(existing),
|
||||
duplicateOf: linkedCode,
|
||||
};
|
||||
}
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
|
||||
: {}),
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
sourceRef,
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
...tahesabProfilePatch(row, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
} else {
|
||||
const profile = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: {},
|
||||
});
|
||||
const created = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
|
||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
birthDate: pickBirthDate(row),
|
||||
birthDate: (profile.birthDate as Date | undefined) ?? null,
|
||||
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
|
||||
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
|
||||
},
|
||||
});
|
||||
personId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (preserveExistingLink) {
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { personId, link: this.linkDto(existing) };
|
||||
}
|
||||
|
||||
const link = await this.prisma.personAccountingLink.upsert({
|
||||
where: {
|
||||
personId_connectionId: {
|
||||
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
/** پروفایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...(!existing.birthDate && pickBirthDate(row)
|
||||
? { birthDate: pickBirthDate(row) }
|
||||
: {}),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
...tahesabProfilePatch(row, existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let duplicate = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
await this.importFromTahesab(
|
||||
const created = await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
imported += 1;
|
||||
if (created.duplicateOf) duplicate += 1;
|
||||
else imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
||||
const summary = [
|
||||
`${imported} وارد شد`,
|
||||
`${updated} بهروز شد`,
|
||||
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
|
||||
`${failed} خطا`,
|
||||
].join('، ');
|
||||
const error = errors.length ? errors.join('؛ ') : null;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: summary,
|
||||
customerSyncLastError: error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
duplicate,
|
||||
failed,
|
||||
summary,
|
||||
error: errors.length ? errors.join('؛ ') : null,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
|
||||
try {
|
||||
const linkedPersonId = linkedByCode.get(moshtariCode);
|
||||
if (linkedPersonId) {
|
||||
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
|
||||
if (birthDate) {
|
||||
await this.prisma.person.updateMany({
|
||||
where: { id: linkedPersonId, birthDate: null },
|
||||
data: { birthDate },
|
||||
});
|
||||
const row = rowsByCode.get(moshtariCode);
|
||||
if (row) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
moshtariCode,
|
||||
row,
|
||||
);
|
||||
}
|
||||
await this.applyTahesabGroup(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
rowsByCode.get(moshtariCode),
|
||||
);
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
|
||||
{ connectionId: dto.connectionId, moshtariCode },
|
||||
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
|
||||
);
|
||||
if (r.duplicateOf) {
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
personId: r.personId,
|
||||
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push({ moshtariCode, ok: true, personId: r.personId });
|
||||
} catch (e) {
|
||||
const message =
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SearchTahesabDto,
|
||||
SearchTahesabProductsDto,
|
||||
SyncAsnadReportDto,
|
||||
SyncAllCustomersDto,
|
||||
SyncGroupsPageDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
UpdateTahesabInvoiceSettingsDto,
|
||||
@@ -237,6 +238,16 @@ export class TahesabController {
|
||||
return this.links.syncGroupsPage(user.organizationId, dto);
|
||||
}
|
||||
|
||||
/** همهٔ مشتریان تهحساب: تازهها وارد، لینکشدهها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) بهروز */
|
||||
@Post('sync/customers-now')
|
||||
@Roles('owner', 'admin')
|
||||
syncCustomersNow(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SyncAllCustomersDto,
|
||||
) {
|
||||
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
|
||||
}
|
||||
|
||||
@Get('sync/unlinked-people')
|
||||
@Roles('owner', 'admin')
|
||||
unlinkedPeople(
|
||||
|
||||
@@ -176,22 +176,26 @@ export class LeadsService {
|
||||
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
|
||||
if (createdRange) and.push(createdRange);
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
},
|
||||
...(opts.unassigned ? [{ assigneeId: null }] : []),
|
||||
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1185,7 +1189,7 @@ export class LeadsService {
|
||||
newStage
|
||||
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
||||
: Promise.resolve(0),
|
||||
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
|
||||
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
|
||||
convertedIds.length
|
||||
? this.prisma.lead.count({
|
||||
where: { ...base, stageId: { in: convertedIds } },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -70,6 +71,16 @@ export class MembershipsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
|
||||
async assertSoleOwner(organizationId: string, userId: string) {
|
||||
const others = await this.countActiveOwners(organizationId, userId);
|
||||
if (others > 0) {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createMembership(data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +89,9 @@ export class MembershipsService {
|
||||
commissionPercent?: number;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
if (data.role === 'owner' && data.isActive !== false) {
|
||||
await this.assertSoleOwner(data.organizationId, data.userId);
|
||||
}
|
||||
return this.prisma.membership.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { OutboundSmsLogService } from './outbound-sms-log.service';
|
||||
import { SmsTemplatesController } from './sms-templates.controller';
|
||||
import { SmsTemplatesService } from './sms-templates.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [OutboundSmsLogService],
|
||||
exports: [OutboundSmsLogService],
|
||||
controllers: [SmsTemplatesController],
|
||||
providers: [OutboundSmsLogService, SmsTemplatesService],
|
||||
exports: [OutboundSmsLogService, SmsTemplatesService],
|
||||
})
|
||||
export class OutboundSmsModule {}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
CreateSmsTemplateDto,
|
||||
UpdateSmsTemplateDto,
|
||||
} from './sms-templates.dto';
|
||||
import { SmsTemplatesService } from './sms-templates.service';
|
||||
|
||||
@Controller('sms-templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SmsTemplatesController {
|
||||
constructor(private readonly templates: SmsTemplatesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: Actor) {
|
||||
return this.templates.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateSmsTemplateDto) {
|
||||
return this.templates.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSmsTemplateDto,
|
||||
) {
|
||||
return this.templates.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.templates.remove(user.organizationId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { SMS_TEMPLATE_BODY_MAX, SMS_TEMPLATE_NAME_MAX } from '@mento/shared';
|
||||
|
||||
export class CreateSmsTemplateDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(SMS_TEMPLATE_NAME_MAX)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(SMS_TEMPLATE_BODY_MAX)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
export class UpdateSmsTemplateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(SMS_TEMPLATE_NAME_MAX)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(SMS_TEMPLATE_BODY_MAX)
|
||||
body?: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
parseSmsTemplates,
|
||||
SMS_TEMPLATE_MAX,
|
||||
type SmsTemplateDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
CreateSmsTemplateDto,
|
||||
UpdateSmsTemplateDto,
|
||||
} from './sms-templates.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SmsTemplatesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string): Promise<SmsTemplateDto[]> {
|
||||
const row = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { smsTemplates: true },
|
||||
});
|
||||
return parseSmsTemplates(row?.smsTemplates);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateSmsTemplateDto,
|
||||
): Promise<SmsTemplateDto[]> {
|
||||
const current = await this.list(organizationId);
|
||||
if (current.length >= SMS_TEMPLATE_MAX) {
|
||||
throw new BadRequestException(
|
||||
`حداکثر ${SMS_TEMPLATE_MAX} پیامک آماده میتوانید داشته باشید.`,
|
||||
);
|
||||
}
|
||||
const next: SmsTemplateDto[] = [
|
||||
...current,
|
||||
{
|
||||
id: randomUUID(),
|
||||
name: dto.name.trim(),
|
||||
body: dto.body.trim(),
|
||||
},
|
||||
];
|
||||
return this.persist(organizationId, next);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateSmsTemplateDto,
|
||||
): Promise<SmsTemplateDto[]> {
|
||||
const current = await this.list(organizationId);
|
||||
const idx = current.findIndex((item) => item.id === id);
|
||||
if (idx < 0) throw new NotFoundException('پیامک آماده پیدا نشد.');
|
||||
const prev = current[idx]!;
|
||||
const next = [...current];
|
||||
next[idx] = {
|
||||
id: prev.id,
|
||||
name: dto.name !== undefined ? dto.name.trim() : prev.name,
|
||||
body: dto.body !== undefined ? dto.body.trim() : prev.body,
|
||||
};
|
||||
return this.persist(organizationId, next);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<SmsTemplateDto[]> {
|
||||
const current = await this.list(organizationId);
|
||||
if (!current.some((item) => item.id === id)) {
|
||||
throw new NotFoundException('پیامک آماده پیدا نشد.');
|
||||
}
|
||||
return this.persist(
|
||||
organizationId,
|
||||
current.filter((item) => item.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
private async persist(
|
||||
organizationId: string,
|
||||
items: SmsTemplateDto[],
|
||||
): Promise<SmsTemplateDto[]> {
|
||||
const stored = parseSmsTemplates(items);
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { smsTemplates: stored as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.pipelinesService.list(user.organizationId);
|
||||
list(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('forLens') forLens?: string,
|
||||
) {
|
||||
return this.pipelinesService.list(user, forLens === '1');
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ActivityStatus,
|
||||
ActivityType,
|
||||
DealActivityCue,
|
||||
DealActivitySummaryDto,
|
||||
DealDto,
|
||||
MergePipelineResultDto,
|
||||
PipelineBoardDto,
|
||||
@@ -102,24 +106,55 @@ export class PipelinesService {
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<PipelineDto[]> {
|
||||
async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
|
||||
const organizationId =
|
||||
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
|
||||
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const rows = await this.prisma.pipeline.findMany({
|
||||
where: { organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
const counts = await this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: { organizationId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const lens = actor?.priorLens;
|
||||
const lensActive = forLens && (lens === 'current' || lens === 'archive');
|
||||
const access = actor ? dealAccessWhere(actor) : {};
|
||||
const prior = lensActive
|
||||
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
|
||||
: {};
|
||||
const [counts, lensCounts] = await Promise.all([
|
||||
this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
lensActive
|
||||
? this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: { organizationId, AND: [access, prior] },
|
||||
_count: { _all: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const dealCountByPipeline = new Map(
|
||||
counts.map((row) => [row.pipelineId, row._count._all]),
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
const lensCountByPipeline = new Map(
|
||||
lensCounts.map((row) => [row.pipelineId, row._count._all]),
|
||||
);
|
||||
const visible = lensActive
|
||||
? rows.filter((row) => {
|
||||
const inLens = lensCountByPipeline.get(row.id) ?? 0;
|
||||
const total = dealCountByPipeline.get(row.id) ?? 0;
|
||||
if (lens === 'archive') return inLens > 0;
|
||||
return inLens > 0 || total === 0;
|
||||
})
|
||||
: rows;
|
||||
return visible.map((row) => ({
|
||||
...this.toPipelineDto(row),
|
||||
dealCount: dealCountByPipeline.get(row.id) ?? 0,
|
||||
dealCount: lensActive
|
||||
? (lensCountByPipeline.get(row.id) ?? 0)
|
||||
: (dealCountByPipeline.get(row.id) ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -343,12 +378,23 @@ export class PipelinesService {
|
||||
});
|
||||
|
||||
const rateMap = await loadOrgRates(this.prisma, organizationId);
|
||||
const activityHints = await this.loadDealActivityHints(
|
||||
organizationId,
|
||||
deals.map((deal) => deal.id),
|
||||
);
|
||||
|
||||
const byStage = new Map<string, DealDto[]>();
|
||||
for (const stage of resolved.stages) byStage.set(stage.id, []);
|
||||
for (const deal of deals) {
|
||||
const list = byStage.get(deal.stageId);
|
||||
if (list) list.push(this.toDealDto(deal));
|
||||
if (list) {
|
||||
const hint = activityHints.get(deal.id);
|
||||
list.push({
|
||||
...this.toDealDto(deal),
|
||||
activityCue: hint?.cue ?? 'none',
|
||||
activitySummaries: hint?.summaries ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -566,6 +612,105 @@ export class PipelinesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async loadDealActivityHints(
|
||||
organizationId: string,
|
||||
dealIds: string[],
|
||||
): Promise<
|
||||
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
|
||||
> {
|
||||
const hints = new Map<
|
||||
string,
|
||||
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
|
||||
>();
|
||||
if (!dealIds.length) return hints;
|
||||
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
dealId: { in: dealIds },
|
||||
status: { in: ['open', 'done'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
dealId: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
dueAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: 'open' | 'done';
|
||||
dueAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
};
|
||||
const byDeal = new Map<string, Row[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.dealId) continue;
|
||||
const list = byDeal.get(row.dealId) ?? [];
|
||||
list.push({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
type: row.type,
|
||||
status: row.status === 'done' ? 'done' : 'open',
|
||||
dueAt: row.dueAt,
|
||||
completedAt: row.completedAt,
|
||||
});
|
||||
byDeal.set(row.dealId, list);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
|
||||
if (item.status === 'done') return 'done';
|
||||
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
|
||||
return 'upcoming';
|
||||
};
|
||||
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
|
||||
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
|
||||
|
||||
for (const dealId of dealIds) {
|
||||
const list = byDeal.get(dealId) ?? [];
|
||||
const summaries = list
|
||||
.map((item) => {
|
||||
const cue = cueOf(item);
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as ActivityType,
|
||||
status: item.status as ActivityStatus,
|
||||
dueAt: item.dueAt?.toISOString() ?? null,
|
||||
cue,
|
||||
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
|
||||
_done: item.completedAt?.getTime() ?? 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const byRank = rank(a.cue) - rank(b.cue);
|
||||
if (byRank) return byRank;
|
||||
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
|
||||
return a._due - b._due;
|
||||
})
|
||||
.map(({ _due: _d, _done: _c, ...summary }) => summary);
|
||||
|
||||
const open = list.filter((item) => item.status === 'open');
|
||||
let cue: DealActivityCue = 'none';
|
||||
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
|
||||
cue = 'overdue';
|
||||
} else if (open.length) {
|
||||
cue = 'upcoming';
|
||||
} else if (list.some((item) => item.status === 'done')) {
|
||||
cue = 'done';
|
||||
}
|
||||
hints.set(dealId, { cue, summaries });
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
toDealDto(row: DealRow): DealDto {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { USER_ROLES, type UserRole } from '@mento/shared';
|
||||
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
|
||||
|
||||
export class PlatformLoginDto {
|
||||
@IsString()
|
||||
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
|
||||
quotaGb!: number | null;
|
||||
}
|
||||
|
||||
export class ApplyProductPlanDto {
|
||||
@IsIn(PRODUCT_PLAN_CODES)
|
||||
planCode!: ProductPlanCode;
|
||||
}
|
||||
|
||||
export class UpdateOrgModulesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
ApplyBillingPlanDto,
|
||||
ApplyProductPlanDto,
|
||||
SetStorageQuotaDto,
|
||||
SetUserCapDto,
|
||||
CreatePlatformAdminDto,
|
||||
@@ -168,6 +169,11 @@ export class PlatformController {
|
||||
return this.platform.setUserCap(id, dto.maxUsers);
|
||||
}
|
||||
|
||||
@Post('organizations/:id/product-plan')
|
||||
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
|
||||
return this.platform.applyProductPlan(id, dto.planCode);
|
||||
}
|
||||
|
||||
@Patch('organizations/:id/modules')
|
||||
updateOrganizationModules(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PlatformOrganizationDto,
|
||||
PlatformStatsDto,
|
||||
PlatformUserDto,
|
||||
ProductPlanCode,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { isBusinessField } from '@mento/shared';
|
||||
@@ -472,6 +473,16 @@ export class PlatformService {
|
||||
return this.getUser(userId);
|
||||
}
|
||||
|
||||
async applyProductPlan(id: string, planCode: ProductPlanCode) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||
await this.billing.applyProductPlan(id, planCode);
|
||||
return this.getOrganization(id);
|
||||
}
|
||||
|
||||
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
@@ -532,6 +543,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
if (isActive && !row.isActive) {
|
||||
if (row.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
||||
}
|
||||
if (!isActive && row.role === 'owner') {
|
||||
@@ -562,6 +576,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
|
||||
if (role === 'owner' && row.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
if (row.role === 'owner' && role !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
@@ -569,7 +586,7 @@ export class PlatformService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customersPersonWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
@@ -54,7 +55,7 @@ export class ReportsService {
|
||||
const invoiceScope = ownerWhere(actor, 'finance');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const ticketScope = ticketAccessWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const personScope = customersPersonWhere(actor);
|
||||
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
@@ -1534,7 +1535,7 @@ export class ReportsService {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor, 'deals');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const personScope = customersPersonWhere(actor);
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
const now = new Date();
|
||||
const { jy, jm } = dateToJalali(now);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
treasurySectionAllowed,
|
||||
type TreasurySectionKey,
|
||||
} from '@mento/shared';
|
||||
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
const permissions = permissionsForTreasury(
|
||||
const permissions = permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
);
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (
|
||||
isLookupForDocuments(method, url) &&
|
||||
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||
}
|
||||
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
function permissionsForTreasury(
|
||||
rolePermissions: string[],
|
||||
override: ReturnType<typeof parseUserTreasuryAccess>,
|
||||
): string[] {
|
||||
if (!override) return rolePermissions;
|
||||
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
|
||||
if (!override.enabled) return rest;
|
||||
return ['treasury', ...override.sections, ...rest];
|
||||
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریتشان جدا میماند. */
|
||||
function isLookupForDocuments(method: string, url: string): boolean {
|
||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
|
||||
}
|
||||
|
||||
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
|
||||
|
||||
@@ -13,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
function reasonText(value?: string | null) {
|
||||
const text = value?.trim() ?? '';
|
||||
return text ? text.slice(0, 500) : null;
|
||||
}
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
|
||||
if (paid.lessThanOrEqualTo(0)) return 'open';
|
||||
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
|
||||
return 'partial';
|
||||
}
|
||||
function periodKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -62,8 +71,11 @@ export class TreasuryOpsService {
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
benefitsReason?: string | null;
|
||||
bonus?: string | number;
|
||||
bonusReason?: string | null;
|
||||
deductions?: string | number;
|
||||
deductionsReason?: string | null;
|
||||
dueDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
@@ -88,8 +100,11 @@ export class TreasuryOpsService {
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
||||
bonus,
|
||||
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
||||
deductions,
|
||||
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
||||
payable,
|
||||
dueDate: parseDate(input.dueDate) ?? new Date(),
|
||||
notes: input.notes?.trim() || null,
|
||||
@@ -137,7 +152,7 @@ export class TreasuryOpsService {
|
||||
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
|
||||
const status = payrollStatus(row.payable, paid.plus(amount));
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
@@ -158,6 +173,123 @@ export class TreasuryOpsService {
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async updatePayroll(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: {
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
benefitsReason?: string | null;
|
||||
bonus?: string | number;
|
||||
bonusReason?: string | null;
|
||||
deductions?: string | number;
|
||||
deductionsReason?: string | null;
|
||||
},
|
||||
) {
|
||||
const row = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void') {
|
||||
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
|
||||
}
|
||||
const base = dec(input.baseAmount);
|
||||
const benefits = dec(input.benefits ?? 0);
|
||||
const bonus = dec(input.bonus ?? 0);
|
||||
const deductions = dec(input.deductions ?? 0);
|
||||
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
||||
if (!input.userId || !input.period?.trim()) {
|
||||
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
||||
}
|
||||
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
||||
const paid = this.paidOf(row.movements);
|
||||
if (payable.lessThan(paid)) {
|
||||
throw new BadRequestException('مبلغ قابل پرداخت از پرداختهای ثبتشده کمتر است.');
|
||||
}
|
||||
const member = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
||||
});
|
||||
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
||||
try {
|
||||
await this.prisma.financialPayroll.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
userId: input.userId,
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
||||
bonus,
|
||||
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
||||
deductions,
|
||||
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
||||
payable,
|
||||
status: payrollStatus(payable, paid),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async updatePayrollPayment(
|
||||
actor: Actor,
|
||||
payrollId: string,
|
||||
movementId: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string },
|
||||
) {
|
||||
const movement = await this.prisma.financialMovement.findFirst({
|
||||
where: {
|
||||
id: movementId,
|
||||
payrollId,
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
},
|
||||
});
|
||||
const payroll = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id: payrollId, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
|
||||
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
|
||||
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
|
||||
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!account || (!account.isActive && account.id !== movement.accountId)) {
|
||||
throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
}
|
||||
const available = await this.balance(account.id);
|
||||
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
|
||||
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.update({
|
||||
where: { id: movement.id },
|
||||
data: {
|
||||
accountId: account.id,
|
||||
amount,
|
||||
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialPayroll.update({
|
||||
where: { id: payroll.id },
|
||||
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
|
||||
}),
|
||||
]);
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async listRecurring(actor: Actor) {
|
||||
const rows = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
@@ -495,8 +627,11 @@ export class TreasuryOpsService {
|
||||
period: string;
|
||||
baseAmount: Prisma.Decimal;
|
||||
benefits: Prisma.Decimal;
|
||||
benefitsReason?: string | null;
|
||||
bonus: Prisma.Decimal;
|
||||
bonusReason?: string | null;
|
||||
deductions: Prisma.Decimal;
|
||||
deductionsReason?: string | null;
|
||||
payable: Prisma.Decimal;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
@@ -504,6 +639,7 @@ export class TreasuryOpsService {
|
||||
id: string;
|
||||
amount: Prisma.Decimal;
|
||||
voidedAt: Date | null;
|
||||
accountId: string;
|
||||
occurredAt: Date;
|
||||
account?: { name: string } | null;
|
||||
}>;
|
||||
@@ -519,8 +655,11 @@ export class TreasuryOpsService {
|
||||
period: row.period,
|
||||
baseAmount: money(row.baseAmount),
|
||||
benefits: money(row.benefits),
|
||||
benefitsReason: row.benefitsReason?.trim() || null,
|
||||
bonus: money(row.bonus),
|
||||
bonusReason: row.bonusReason?.trim() || null,
|
||||
deductions: money(row.deductions),
|
||||
deductionsReason: row.deductionsReason?.trim() || null,
|
||||
payable: money(row.payable),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(left.isNegative() ? 0 : left),
|
||||
@@ -532,6 +671,7 @@ export class TreasuryOpsService {
|
||||
id: item.id,
|
||||
amount: money(item.amount),
|
||||
occurredAt: item.occurredAt.toISOString(),
|
||||
accountId: item.accountId,
|
||||
accountName: item.account?.name ?? '',
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -129,6 +129,29 @@ export class TreasuryController {
|
||||
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
|
||||
}
|
||||
|
||||
@Post('party-balances/collect')
|
||||
collectPartyBalance(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body()
|
||||
body: {
|
||||
kind?: string;
|
||||
partyKind?: 'person' | 'company' | 'user';
|
||||
partyId?: string;
|
||||
amount?: string | number;
|
||||
accountId?: string;
|
||||
occurredAt?: string;
|
||||
},
|
||||
) {
|
||||
return this.treasury.collectPartyBalance(user, {
|
||||
kind: body.kind === 'expense' ? 'expense' : 'income',
|
||||
partyKind: body.partyKind ?? 'person',
|
||||
partyId: body.partyId ?? '',
|
||||
amount: body.amount ?? 0,
|
||||
accountId: body.accountId ?? '',
|
||||
occurredAt: body.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -232,6 +255,25 @@ export class TreasuryController {
|
||||
return this.ops.createPayroll(user, body);
|
||||
}
|
||||
|
||||
@Patch('payroll/:id')
|
||||
updatePayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
|
||||
) {
|
||||
return this.ops.updatePayroll(user, id, body);
|
||||
}
|
||||
|
||||
@Patch('payroll/:id/payments/:movementId')
|
||||
updatePayrollPayment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('movementId') movementId: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
|
||||
) {
|
||||
return this.ops.updatePayrollPayment(user, id, movementId, body);
|
||||
}
|
||||
|
||||
@Post('payroll/:id/pay')
|
||||
payPayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -360,6 +360,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
kind: { not: 'allocation' },
|
||||
...(from || to
|
||||
? {
|
||||
occurredAt: {
|
||||
@@ -378,6 +379,7 @@ export class TreasuryService {
|
||||
organizationId: actor.organizationId,
|
||||
accountId,
|
||||
voidedAt: null,
|
||||
kind: { not: 'allocation' },
|
||||
occurredAt: { lt: from },
|
||||
},
|
||||
select: { direction: true, amount: true },
|
||||
@@ -531,6 +533,8 @@ export class TreasuryService {
|
||||
settleAmount?: string | number | null;
|
||||
accountId?: string | null;
|
||||
settledAt?: string | null;
|
||||
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمیسازد. */
|
||||
settlement?: boolean;
|
||||
},
|
||||
): Promise<FinancialDocumentDto> {
|
||||
const title = input.title.trim();
|
||||
@@ -561,6 +565,17 @@ export class TreasuryService {
|
||||
}
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const settlement = input.settlement === true;
|
||||
if (settlement) {
|
||||
if (party.partyKind === 'other') {
|
||||
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
|
||||
}
|
||||
if (!settle.equals(amount) || !settle.greaterThan(0)) {
|
||||
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
|
||||
}
|
||||
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
|
||||
}
|
||||
const payExisting = settlement;
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
@@ -607,6 +622,45 @@ export class TreasuryService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payExisting && input.accountId) {
|
||||
const leftover = await this.allocatePartyBalance(tx, actor, {
|
||||
party,
|
||||
sourceId: created.id,
|
||||
kind: input.kind,
|
||||
amount: settle,
|
||||
accountId: input.accountId,
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
});
|
||||
if (leftover.greaterThan(0)) {
|
||||
const opposite = input.kind === 'income' ? 'expense' : 'income';
|
||||
const oppositeRef = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
opposite,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: opposite,
|
||||
title,
|
||||
amount: leftover,
|
||||
bookedAt,
|
||||
personId: party.personId,
|
||||
companyId: party.companyId,
|
||||
userId: party.userId,
|
||||
partyKind: party.partyKind,
|
||||
partyName: party.partyName,
|
||||
status: 'open',
|
||||
reference: oppositeRef,
|
||||
notes: `source:${created.id}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return tx.financialDocument.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
@@ -728,6 +782,7 @@ export class TreasuryService {
|
||||
amount: Prisma.Decimal;
|
||||
settled: Prisma.Decimal;
|
||||
remaining: Prisma.Decimal;
|
||||
documentRemaining: Prisma.Decimal;
|
||||
}>();
|
||||
for (const row of rows) {
|
||||
if (row.partyKind === 'other') continue;
|
||||
@@ -748,10 +803,12 @@ export class TreasuryService {
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
documentRemaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(row.amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
current.remaining = current.remaining.plus(rawRemaining);
|
||||
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
|
||||
buckets.set(key, current);
|
||||
}
|
||||
|
||||
@@ -770,6 +827,7 @@ export class TreasuryService {
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
documentRemaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
@@ -790,9 +848,122 @@ export class TreasuryService {
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(row.settled),
|
||||
remaining: money(row.remaining),
|
||||
documentRemaining: money(row.documentRemaining),
|
||||
}));
|
||||
}
|
||||
|
||||
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
|
||||
async collectPartyBalance(
|
||||
actor: Actor,
|
||||
input: {
|
||||
kind: 'income' | 'expense';
|
||||
partyKind: 'person' | 'company' | 'user';
|
||||
partyId: string;
|
||||
amount: string | number;
|
||||
accountId: string;
|
||||
occurredAt?: string;
|
||||
},
|
||||
): Promise<{ applied: string; documentCount: number }> {
|
||||
const amount = dec(input.amount);
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const partyId = input.partyId.trim();
|
||||
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
|
||||
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
|
||||
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
|
||||
}
|
||||
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
const scope = await this.accountScope(actor);
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind: input.kind,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
partyKind: input.partyKind,
|
||||
...(input.partyKind === 'person' ? { personId: partyId } : {}),
|
||||
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
|
||||
...(input.partyKind === 'user' ? { userId: partyId } : {}),
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const open = docs
|
||||
.map((doc) => {
|
||||
const remaining = doc.amount.minus(this.settledOf(doc.movements));
|
||||
return { doc, remaining };
|
||||
})
|
||||
.filter((row) => row.remaining.greaterThan(0));
|
||||
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
|
||||
if (!available.greaterThan(0)) {
|
||||
throw new BadRequestException(
|
||||
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
|
||||
);
|
||||
}
|
||||
if (amount.greaterThan(available)) {
|
||||
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
|
||||
}
|
||||
if (input.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
|
||||
let left = amount;
|
||||
const touched: string[] = [];
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const row of open) {
|
||||
if (!left.greaterThan(0)) break;
|
||||
const slice = left.lessThan(row.remaining) ? left : row.remaining;
|
||||
const status = slice.equals(row.remaining) ? 'settled' : 'partial';
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: account.id,
|
||||
documentId: row.doc.id,
|
||||
direction: input.kind === 'income' ? 'in' : 'out',
|
||||
amount: slice,
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt,
|
||||
memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده',
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: row.doc.id },
|
||||
data: { status, accountId: account.id },
|
||||
});
|
||||
if (status === 'settled') {
|
||||
await tx.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: row.doc.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
touched.push(row.doc.id);
|
||||
left = left.minus(slice);
|
||||
}
|
||||
});
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'document',
|
||||
entityId: touched[0] ?? partyId,
|
||||
action: 'settle',
|
||||
after: {
|
||||
amount: amount.toFixed(2),
|
||||
accountId: account.id,
|
||||
documentIds: touched,
|
||||
partyId,
|
||||
},
|
||||
actorUserId: actor.userId,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return { applied: money(amount), documentCount: touched.length };
|
||||
}
|
||||
|
||||
private async resolveParty(
|
||||
actor: Actor,
|
||||
input: {
|
||||
@@ -936,16 +1107,51 @@ export class TreasuryService {
|
||||
});
|
||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.updateMany({
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const allocations = await tx.financialMovement.findMany({
|
||||
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
|
||||
select: { documentId: true },
|
||||
});
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { documentId: doc.id, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
}),
|
||||
this.prisma.financialDocument.update({
|
||||
});
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
|
||||
data: { voidedAt: now },
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
const excess = await tx.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
notes: `source:${doc.id}`,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
},
|
||||
include: { movements: { select: { voidedAt: true } } },
|
||||
});
|
||||
for (const child of excess) {
|
||||
if (child.movements.some((row) => !row.voidedAt)) continue;
|
||||
await tx.financialDocument.update({
|
||||
where: { id: child.id },
|
||||
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
|
||||
});
|
||||
}
|
||||
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
|
||||
for (const documentId of affected) {
|
||||
const row = await tx.financialDocument.findUnique({
|
||||
where: { id: documentId },
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void') continue;
|
||||
const settled = this.settledOf(row.movements);
|
||||
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
|
||||
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
|
||||
}
|
||||
});
|
||||
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||
where: { id: doc.id },
|
||||
include: { category: true, account: true, movements: true },
|
||||
@@ -1150,6 +1356,76 @@ export class TreasuryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم میکند؛ اول سندهای همنوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
|
||||
private async allocatePartyBalance(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: Actor,
|
||||
input: {
|
||||
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
|
||||
sourceId: string;
|
||||
kind: 'income' | 'expense';
|
||||
amount: Prisma.Decimal;
|
||||
accountId: string;
|
||||
occurredAt: Date;
|
||||
memo: string;
|
||||
},
|
||||
): Promise<Prisma.Decimal> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const openDocs = await tx.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
id: { not: input.sourceId },
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
partyKind: input.party.partyKind,
|
||||
...(input.party.personId ? { personId: input.party.personId } : {}),
|
||||
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
|
||||
...(input.party.userId ? { userId: input.party.userId } : {}),
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
include: { movements: { select: { amount: true, voidedAt: true } } },
|
||||
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const ordered = [
|
||||
...openDocs.filter((row) => row.kind === input.kind),
|
||||
...openDocs.filter((row) => row.kind !== input.kind),
|
||||
];
|
||||
let left = input.amount;
|
||||
for (const row of ordered) {
|
||||
if (!left.greaterThan(0)) break;
|
||||
const remaining = row.amount.minus(this.settledOf(row.movements));
|
||||
if (!remaining.greaterThan(0)) continue;
|
||||
const slice = left.lessThan(remaining) ? left : remaining;
|
||||
const status = slice.equals(remaining) ? 'settled' : 'partial';
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: input.accountId,
|
||||
documentId: row.id,
|
||||
direction: row.kind === 'income' ? 'in' : 'out',
|
||||
amount: slice,
|
||||
kind: 'allocation',
|
||||
occurredAt: input.occurredAt,
|
||||
memo: input.memo,
|
||||
reference: `alloc:${input.sourceId}`,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
await tx.financialDocument.update({
|
||||
where: { id: row.id },
|
||||
data: { status },
|
||||
});
|
||||
if (status === 'settled') {
|
||||
await tx.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: row.id, status: 'pending' },
|
||||
data: { status: 'confirmed' },
|
||||
});
|
||||
}
|
||||
left = left.minus(slice);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||
return movements
|
||||
.filter((row) => !row.voidedAt)
|
||||
@@ -1158,7 +1434,7 @@ export class TreasuryService {
|
||||
|
||||
private async accountBalance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
@@ -1233,7 +1509,7 @@ export class TreasuryService {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const movements = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId: account.id, voidedAt: null },
|
||||
where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
|
||||
select: { direction: true, amount: true, kind: true, occurredAt: true },
|
||||
});
|
||||
let balance = new Prisma.Decimal(0);
|
||||
|
||||
@@ -104,8 +104,10 @@ export class UsersService {
|
||||
orgRoleId: dto.orgRoleId,
|
||||
role: dto.role,
|
||||
});
|
||||
if (resolved.role === 'owner' && actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
||||
if (resolved.role === 'owner') {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
@@ -449,6 +451,10 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRole === 'owner' && target.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, target.userId);
|
||||
}
|
||||
|
||||
if (target.role === 'owner' && nextRole !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
organizationId,
|
||||
@@ -456,7 +462,7 @@ export class UsersService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -528,6 +534,9 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
if (isActive && !target.isActive) {
|
||||
if (target.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, targetId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(organizationId, targetId);
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
@@ -558,6 +567,9 @@ export class UsersService {
|
||||
resolved: { role: UserRole; orgRoleId: string },
|
||||
): Promise<OrgUserDto> {
|
||||
await this.billing.assertSeatAvailable(organizationId, userId);
|
||||
if (resolved.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, userId);
|
||||
}
|
||||
const existing = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access';
|
||||
import {
|
||||
customersCompanyWhere,
|
||||
customersPersonWhere,
|
||||
leadsDeepWhere,
|
||||
resolveSectionAccess,
|
||||
} from '../src/common/data-access';
|
||||
import {
|
||||
noteAccessWhere,
|
||||
normalizeDataVisibility,
|
||||
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخشها را ذ
|
||||
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('مسئول گروه مشتری روی عمق داده', () => {
|
||||
const selfAccess = { mode: 'rules' as const, rules: [{ owners: ['self' as const] }] };
|
||||
|
||||
it('محدودیت self با OR مسئول گروه برای شخص', () => {
|
||||
const where = customersPersonWhere({
|
||||
userId: 'u1',
|
||||
role: 'sales',
|
||||
customersAccess: selfAccess,
|
||||
teamPeerIds: null,
|
||||
});
|
||||
assert.deepEqual(where, {
|
||||
OR: [
|
||||
{ ownerId: 'u1' },
|
||||
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('محدودیت self با OR مسئول گروه برای شرکت', () => {
|
||||
const where = customersCompanyWhere({
|
||||
userId: 'u1',
|
||||
role: 'sales',
|
||||
customersAccess: selfAccess,
|
||||
teamPeerIds: null,
|
||||
});
|
||||
assert.deepEqual(where, {
|
||||
OR: [
|
||||
{ ownerId: 'u1' },
|
||||
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('بدون محدودیت عمقی، فیلتر اضافه نمیشود', () => {
|
||||
assert.deepEqual(
|
||||
customersPersonWhere({
|
||||
userId: 'u1',
|
||||
role: 'sales',
|
||||
customersAccess: { mode: 'all', rules: [] },
|
||||
teamPeerIds: null,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,9 @@ function apply() {
|
||||
<strong>{{ hasSort ? 'فیلتر و مرتبسازی' : 'فیلترها' }}</strong>
|
||||
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
|
||||
</header>
|
||||
<p v-if="isPeople" class="facet-hint">
|
||||
گروه، منبع و وضعیت با هم جمع میشوند؛ هر کدام کافی است (بدون تکرار).
|
||||
</p>
|
||||
|
||||
<div class="panel-body">
|
||||
<section v-if="hasSort" class="sec">
|
||||
@@ -264,6 +267,15 @@ function apply() {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.facet-hint {
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.9rem 0.55rem;
|
||||
font-size: var(--fs-sm);
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
||||
import type { CustomerGroupColor, CustomerGroupDto, OrgUserDto } from '@mento/shared';
|
||||
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { normalizeSearchText } from '@/lib/search';
|
||||
import {
|
||||
CUSTOMER_GROUP_COLOR_LABELS,
|
||||
groupSwatch,
|
||||
useCustomerGroups,
|
||||
} from '@/lib/customer-groups';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { UiButton, UiInput, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const canManage = computed(
|
||||
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
|
||||
);
|
||||
|
||||
const { groups, load } = useCustomerGroups();
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const newName = ref('');
|
||||
const newColor = ref<CustomerGroupColor>('teal');
|
||||
const newAssigneeIds = ref<string[]>([]);
|
||||
const editingId = ref<string | null>(null);
|
||||
const editName = ref('');
|
||||
const assigneeDraft = ref<Record<string, string[]>>({});
|
||||
const listQuery = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const q = normalizeSearchText(listQuery.value);
|
||||
if (!q) return groups.value;
|
||||
return groups.value.filter((g) => {
|
||||
const assigneeNames = (g.assignees ?? []).map((a) => a.fullName).join(' ');
|
||||
return (
|
||||
normalizeSearchText(g.name).includes(q) ||
|
||||
normalizeSearchText(g.parentName ?? '').includes(q) ||
|
||||
normalizeSearchText(assigneeNames).includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function errorText(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||||
users.value = data.filter((u) => u.isActive !== false);
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function syncAssigneeDraft() {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const g of groups.value) {
|
||||
next[g.id] = (g.assignees ?? []).map((a) => a.id);
|
||||
}
|
||||
assigneeDraft.value = next;
|
||||
}
|
||||
|
||||
async function run(task: () => Promise<unknown>, fallback: string) {
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await task();
|
||||
await load(true);
|
||||
syncAssigneeDraft();
|
||||
emit('changed');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -53,10 +96,18 @@ async function create() {
|
||||
const name = newName.value.trim();
|
||||
if (!name) return;
|
||||
const ok = await run(
|
||||
() => api.post('/customer-groups', { name, color: newColor.value }),
|
||||
() =>
|
||||
api.post('/customer-groups', {
|
||||
name,
|
||||
color: newColor.value,
|
||||
assigneeIds: newAssigneeIds.value,
|
||||
}),
|
||||
'ساخت گروه ناموفق بود.',
|
||||
);
|
||||
if (ok) newName.value = '';
|
||||
if (ok) {
|
||||
newName.value = '';
|
||||
newAssigneeIds.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(g: CustomerGroupDto) {
|
||||
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
|
||||
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
|
||||
}
|
||||
|
||||
async function onAssigneesChange(g: CustomerGroupDto, ids: string[]) {
|
||||
assigneeDraft.value = { ...assigneeDraft.value, [g.id]: ids };
|
||||
const prev = (g.assignees ?? []).map((a) => a.id).sort().join(',');
|
||||
const next = [...ids].sort().join(',');
|
||||
if (prev === next) return;
|
||||
await run(
|
||||
() => api.patch(`/customer-groups/${g.id}`, { assigneeIds: ids }),
|
||||
'ذخیره مسئولان گروه ناموفق بود.',
|
||||
);
|
||||
}
|
||||
|
||||
function assigneeLabel(g: CustomerGroupDto) {
|
||||
const names = (g.assignees ?? []).map((a) => a.fullName).filter(Boolean);
|
||||
if (!names.length) return 'بدون مسئول';
|
||||
if (names.length === 1) return names[0]!;
|
||||
return `${names[0]} و ${toFaDigits(names.length - 1)} نفر دیگر`;
|
||||
}
|
||||
|
||||
async function remove(g: CustomerGroupDto) {
|
||||
const used = g.personCount + g.companyCount;
|
||||
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته میشود (خود مخاطبها حذف نمیشوند).` : '';
|
||||
@@ -95,9 +164,12 @@ watch(
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
editingId.value = null;
|
||||
void load(true).catch(() => {
|
||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||
});
|
||||
listQuery.value = '';
|
||||
void Promise.all([load(true), loadUsers()])
|
||||
.then(() => syncAssigneeDraft())
|
||||
.catch(() => {
|
||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -107,34 +179,60 @@ watch(
|
||||
<UiModal :open="open" title="گروههای مشتری" @close="emit('close')">
|
||||
<div class="manager">
|
||||
<p class="lead">
|
||||
هر شخص یا شرکت میتواند چند گروه داشته باشد. گروههایی که از تهحساب میآیند با برچسب
|
||||
«تهحساب» مشخص شدهاند.
|
||||
هر شخص یا شرکت میتواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
|
||||
مشتریان خود میبینند. گروههای تهحساب با برچسب «تهحساب» مشخص شدهاند.
|
||||
</p>
|
||||
|
||||
<form class="new-row" @submit.prevent="create">
|
||||
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ on: newColor === c }"
|
||||
:style="{ background: groupSwatch(c).fg }"
|
||||
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
|
||||
@click="newColor = c"
|
||||
<form v-if="canManage" class="create-panel" @submit.prevent="create">
|
||||
<header class="create-head">
|
||||
<strong>گروه جدید</strong>
|
||||
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
|
||||
</header>
|
||||
<div class="new-row">
|
||||
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||
:key="c"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ on: newColor === c }"
|
||||
:style="{ background: groupSwatch(c).fg }"
|
||||
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
|
||||
@click="newColor = c"
|
||||
/>
|
||||
</div>
|
||||
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="users.length" class="assignee-field create-assignees">
|
||||
<span class="assignees-label">مسئولان (اختیاری)</span>
|
||||
<OrgUserPersonPick
|
||||
v-model="newAssigneeIds"
|
||||
:users="users"
|
||||
:disabled="busy"
|
||||
empty-label="انتخاب مسئول"
|
||||
/>
|
||||
</div>
|
||||
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
|
||||
<UiIcon name="plus" :size="15" />
|
||||
افزودن
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<ul v-if="groups.length" class="list">
|
||||
<li v-for="g in groups" :key="g.id" class="row">
|
||||
<div v-if="canManage || groups.length" class="list-head">
|
||||
<h3 class="list-title">گروههای موجود</h3>
|
||||
<label v-if="groups.length" class="list-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
v-model="listQuery"
|
||||
type="search"
|
||||
placeholder="جستجوی گروه یا مسئول…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ul v-if="filteredGroups.length" class="list">
|
||||
<li v-for="g in filteredGroups" :key="g.id" class="row">
|
||||
<div class="name-cell">
|
||||
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
|
||||
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
|
||||
@@ -152,9 +250,10 @@ watch(
|
||||
<span class="count">
|
||||
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
|
||||
</span>
|
||||
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="editingId !== g.id" class="row-actions">
|
||||
<div v-if="editingId !== g.id && canManage" class="row-actions">
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||
@@ -173,8 +272,20 @@ watch(
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="canManage && users.length" class="assignee-field">
|
||||
<span class="assignees-label">مسئولان</span>
|
||||
<OrgUserPersonPick
|
||||
:model-value="assigneeDraft[g.id] ?? []"
|
||||
:users="users"
|
||||
:disabled="busy"
|
||||
empty-label="انتخاب مسئول"
|
||||
@update:model-value="(ids) => (assigneeDraft[g.id] = ids)"
|
||||
@change="(ids) => onAssigneesChange(g, ids)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
|
||||
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
|
||||
</div>
|
||||
</UiModal>
|
||||
@@ -200,6 +311,78 @@ watch(
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #0d9488) 28%, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, #fff) 0%, #fff 72%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.create-head {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.create-head strong {
|
||||
color: var(--accent-deep, #0f766e);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.create-head span {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: min(16rem, 100%);
|
||||
flex: 1 1 12rem;
|
||||
max-width: 18rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.list-search:focus-within {
|
||||
border-color: #5eead4;
|
||||
}
|
||||
|
||||
.list-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.new-row,
|
||||
.edit {
|
||||
display: flex;
|
||||
@@ -240,11 +423,9 @@ watch(
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 0 0 0.25rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
@@ -282,14 +463,38 @@ watch(
|
||||
padding: 0.05rem 0.4rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
.count,
|
||||
.assignee-summary {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.assignee-summary {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.assignee-field {
|
||||
flex: 1 1 100%;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.create-assignees {
|
||||
border-top: 1px dashed color-mix(in srgb, var(--accent, #0d9488) 22%, #e2e8f0);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.assignees-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,6 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: SectionDataAccess;
|
||||
section?: DataAccessSection;
|
||||
groups?: IdOption[];
|
||||
stages?: IdOption[];
|
||||
pipelines?: IdOption[];
|
||||
boards?: IdOption[];
|
||||
@@ -36,7 +35,6 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
section: 'customers',
|
||||
groups: () => [],
|
||||
stages: () => [],
|
||||
pipelines: () => [],
|
||||
boards: () => [],
|
||||
@@ -53,9 +51,9 @@ const emit = defineEmits<{
|
||||
|
||||
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
|
||||
|
||||
const showGroups = computed(() => props.section === 'customers');
|
||||
const showMoods = computed(() => props.section === 'customers');
|
||||
const showImportSources = computed(() => props.section === 'customers');
|
||||
const isCustomers = computed(() => props.section === 'customers');
|
||||
const showLeadSources = computed(() => props.section === 'leads');
|
||||
const showNoteTypes = computed(() => props.section === 'notes');
|
||||
const showTicketStatuses = computed(() => props.section === 'tickets');
|
||||
@@ -119,9 +117,8 @@ const boardsLegend = computed(() =>
|
||||
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینهها) برای ویرایش ساده */
|
||||
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
||||
if (!rules.length) return {};
|
||||
if (rules.length === 1) return { ...rules[0]! };
|
||||
if (rules.length === 1) return stripCustomerGroupIds({ ...rules[0]! });
|
||||
const owners = new Set<DataAccessOwnerKind>();
|
||||
const groupIds = new Set<string>();
|
||||
const sources = new Set<string>();
|
||||
const moods = new Set<PersonMood>();
|
||||
const stageIds = new Set<string>();
|
||||
@@ -130,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
||||
const typeIds = new Set<string>();
|
||||
for (const r of rules) {
|
||||
for (const o of r.owners ?? []) owners.add(o);
|
||||
for (const id of r.groupIds ?? []) groupIds.add(id);
|
||||
for (const s of r.sources ?? []) sources.add(s);
|
||||
for (const m of r.moods ?? []) moods.add(m);
|
||||
for (const id of r.stageIds ?? []) stageIds.add(id);
|
||||
@@ -140,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
||||
}
|
||||
const out: DataAccessRule = {};
|
||||
if (owners.size) out.owners = [...owners];
|
||||
if (groupIds.size) out.groupIds = [...groupIds];
|
||||
if (sources.size) out.sources = [...sources];
|
||||
if (moods.size) out.moods = [...moods];
|
||||
if (stageIds.size) out.stageIds = [...stageIds];
|
||||
@@ -150,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** دسترسی گروه فقط از مسئولگروه؛ groupIds دیگر در فیلتر مشتریان ذخیره/نمایش نمیشود */
|
||||
function stripCustomerGroupIds(rule: DataAccessRule): DataAccessRule {
|
||||
if (!isCustomers.value || !rule.groupIds?.length) return rule;
|
||||
const { groupIds: _drop, ...rest } = rule;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
|
||||
return Boolean(
|
||||
rule.owners?.length ||
|
||||
rule.groupIds?.length ||
|
||||
(!isCustomers.value && rule.groupIds?.length) ||
|
||||
rule.sources?.length ||
|
||||
rule.moods?.length ||
|
||||
rule.stageIds?.length ||
|
||||
@@ -184,10 +186,26 @@ const mode = computed({
|
||||
|
||||
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
|
||||
|
||||
/** پاکسازی groupIds قدیمی مشتریان هنگام باز شدن ویرایشگر */
|
||||
watch(
|
||||
() => [props.section, props.modelValue] as const,
|
||||
() => {
|
||||
if (!isCustomers.value || props.modelValue?.mode !== 'rules') return;
|
||||
const rules = props.modelValue.rules ?? [];
|
||||
if (!rules.some((r) => r.groupIds?.length)) return;
|
||||
const collapsed = collapseRules(rules);
|
||||
emit('update:modelValue', {
|
||||
mode: 'rules',
|
||||
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function emitFilter(next: DataAccessRule) {
|
||||
const cleaned: DataAccessRule = {};
|
||||
if (next.owners?.length) cleaned.owners = [...next.owners];
|
||||
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
|
||||
if (!isCustomers.value && next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
|
||||
if (next.sources?.length) cleaned.sources = [...next.sources];
|
||||
if (next.moods?.length) cleaned.moods = [...next.moods];
|
||||
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
|
||||
@@ -210,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
|
||||
}
|
||||
|
||||
type ListKey =
|
||||
| 'groupIds'
|
||||
| 'sources'
|
||||
| 'moods'
|
||||
| 'stageIds'
|
||||
@@ -293,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
|
||||
const pickers = computed((): Picker[] => {
|
||||
const rule = filter.value;
|
||||
const items: Picker[] = [];
|
||||
if (showGroups.value) {
|
||||
items.push({
|
||||
key: 'groupIds',
|
||||
legend: 'گروهها',
|
||||
mode: 'multiple',
|
||||
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
|
||||
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
|
||||
options: withSelected(namedOptions(props.groups), rule.groupIds),
|
||||
});
|
||||
}
|
||||
if (showImportSources.value) {
|
||||
const ready = props.importSourcesReady;
|
||||
const failed = props.importSourcesFailed;
|
||||
@@ -417,7 +424,6 @@ const filterSummary = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
|
||||
if (rule.owners?.includes('team')) parts.push('تیمهای عضو');
|
||||
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
|
||||
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
|
||||
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
|
||||
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپلاین`);
|
||||
@@ -443,9 +449,12 @@ const filterSummary = computed(() => {
|
||||
|
||||
<template v-if="mode === 'rules'">
|
||||
<p class="hint">
|
||||
فقط رکوردهایی دیده میشوند که با
|
||||
<strong>همهٔ فیلترهای پرشده</strong>
|
||||
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
|
||||
فیلترهای پرشده با هم
|
||||
<strong>جمع میشوند</strong>
|
||||
؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
|
||||
<template v-if="isCustomers">
|
||||
اعضای گروههایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه میشوند.
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="hadMultipleRules" class="hint warn">
|
||||
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
emptySectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -85,7 +84,6 @@ function setSection(key: DataAccessSection, next: SectionDataAccess) {
|
||||
});
|
||||
}
|
||||
|
||||
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
|
||||
const importSources = ref<Array<{ value: string; label: string }>>([]);
|
||||
const importSourcesReady = ref(false);
|
||||
const importSourcesFailed = ref(false);
|
||||
@@ -142,7 +140,6 @@ async function loadTreasuryAccounts() {
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
await loadCustomerGroups().catch(() => undefined);
|
||||
try {
|
||||
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
|
||||
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
|
||||
@@ -362,7 +359,6 @@ const typesForActive = computed(() => {
|
||||
:key="active"
|
||||
:section="editorSection"
|
||||
:model-value="sectionValue(editorSection)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:pipelines="pipelines"
|
||||
:boards="boardsForActive"
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = withDefaults(
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
icon?: 'calendar' | 'clock' | 'bell';
|
||||
/** اگر داده شود، متن باکس بهجای تاریخ خام همین مقدار است. */
|
||||
displayText?: string;
|
||||
/** نمایی که با باز شدن پنل دیده میشود. */
|
||||
startView?: 'days' | 'months' | 'years';
|
||||
}>(),
|
||||
{
|
||||
mode: 'date',
|
||||
@@ -37,6 +41,8 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
icon: undefined,
|
||||
displayText: '',
|
||||
startView: 'days',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
|
||||
});
|
||||
|
||||
const display = computed(() => {
|
||||
if (props.displayText) return props.displayText;
|
||||
if (props.mode === 'time') {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
|
||||
if (!match) return '';
|
||||
@@ -353,7 +360,7 @@ function place() {
|
||||
|
||||
function liftAboveOverlays() {
|
||||
let z = 120;
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
|
||||
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
|
||||
}
|
||||
@@ -368,7 +375,7 @@ function onWindowChange() {
|
||||
async function show() {
|
||||
if (props.disabled) return;
|
||||
syncDraftFrom(parsed.value);
|
||||
calView.value = 'days';
|
||||
calView.value = props.startView;
|
||||
clockFace.value = 'hour';
|
||||
syncViewport();
|
||||
liftAboveOverlays();
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = withDefaults(
|
||||
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
||||
asNumber?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
addonAfter?: string;
|
||||
}>(),
|
||||
{
|
||||
required: false,
|
||||
@@ -30,6 +31,7 @@ const props = withDefaults(
|
||||
decimals: 2,
|
||||
asNumber: false,
|
||||
dir: 'ltr',
|
||||
addonAfter: '',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:value="text"
|
||||
:addon-after="addonAfter || undefined"
|
||||
style="width: 100%; font-variant-numeric: tabular-nums"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { OrgUserDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { normalizeSearchText } from '@/lib/search';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
emptyLabel: 'انتخاب مسئول',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [ids: string[]];
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const query = ref('');
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selected = computed(() =>
|
||||
props.modelValue
|
||||
.map((id) => props.users.find((u) => u.id === id))
|
||||
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
|
||||
);
|
||||
|
||||
const label = computed(() => {
|
||||
if (!selected.value.length) return props.emptyLabel;
|
||||
if (selected.value.length === 1) return selected.value[0]!.fullName;
|
||||
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = normalizeSearchText(query.value);
|
||||
if (!q) return props.users;
|
||||
return props.users.filter(
|
||||
(u) =>
|
||||
normalizeSearchText(u.fullName).includes(q) ||
|
||||
normalizeSearchText(u.orgRoleName ?? '').includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
function toggleMenu() {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function setIds(next: string[]) {
|
||||
emit('update:modelValue', next);
|
||||
emit('change', next);
|
||||
}
|
||||
|
||||
function toggle(userId: string) {
|
||||
if (props.disabled) return;
|
||||
const set = new Set(props.modelValue);
|
||||
if (set.has(userId)) set.delete(userId);
|
||||
else set.add(userId);
|
||||
setIds([...set]);
|
||||
}
|
||||
|
||||
function remove(userId: string) {
|
||||
if (props.disabled) return;
|
||||
if (!props.modelValue.includes(userId)) return;
|
||||
setIds(props.modelValue.filter((id) => id !== userId));
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) query.value = '';
|
||||
});
|
||||
|
||||
defineExpose({ close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" @click.stop>
|
||||
<button
|
||||
class="picker person-pick"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<div class="assignee-avatars">
|
||||
<UserAvatar
|
||||
v-for="user in selected.slice(0, 3)"
|
||||
:key="user.id"
|
||||
:name="user.fullName"
|
||||
:user-id="user.id"
|
||||
:has-avatar="user.hasAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
|
||||
</div>
|
||||
<span :class="{ ghost: !selected.length }">{{ label }}</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
|
||||
<div v-if="selected.length" class="chips">
|
||||
<span v-for="user in selected" :key="user.id" class="chip">
|
||||
{{ user.fullName }}
|
||||
<button
|
||||
type="button"
|
||||
class="chip-x"
|
||||
:disabled="disabled"
|
||||
:aria-label="`حذف ${user.fullName}`"
|
||||
@click="remove(user.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
|
||||
<label class="person-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="جستجوی نام یا نقش…"
|
||||
@keydown.esc="close"
|
||||
/>
|
||||
</label>
|
||||
<ul class="person-list">
|
||||
<li v-for="user in filtered" :key="user.id">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: modelValue.includes(user.id) }"
|
||||
@click="toggle(user.id)"
|
||||
>
|
||||
<span class="check" :class="{ on: modelValue.includes(user.id) }">
|
||||
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar
|
||||
:name="user.fullName"
|
||||
:user-id="user.id"
|
||||
:has-avatar="user.hasAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<span class="role-name">{{ user.fullName }}</span>
|
||||
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
width: 100%;
|
||||
min-height: var(--control-h, 36px);
|
||||
height: var(--control-h, 36px);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picker:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.picker .ghost {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.picker :deep(.ico:last-child) {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assignee-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.assignee-avatars :deep(.avatar),
|
||||
.assignee-avatars > * {
|
||||
margin-inline-end: -0.35rem;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #f0fdfa;
|
||||
border: 1px solid #99f6e4;
|
||||
color: #0f766e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip-x {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chip-x:hover:not(:disabled) {
|
||||
background: #ccfbf1;
|
||||
}
|
||||
|
||||
.chip-x:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 0.25rem);
|
||||
z-index: 40;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.person-menu {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.person-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
height: 2.4rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.person-search:focus-within {
|
||||
border-color: #5eead4;
|
||||
}
|
||||
|
||||
.person-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.person-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: var(--radius, 10px);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.multi-menu .check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.multi-menu .check.on {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.multi-menu .role-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multi-menu .role-count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.menu-empty {
|
||||
padding: 0.6rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
||||
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
|
||||
import type {
|
||||
OutboundSmsHistoryResponse,
|
||||
OutboundSmsLogDto,
|
||||
SmsTemplateDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
MESSAGE_AUTOMATION_FIELDS,
|
||||
SMS_TEMPLATE_MAX,
|
||||
SMS_TEMPLATE_NAME_MAX,
|
||||
countSmsParts,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
@@ -40,13 +49,25 @@ const busy = ref(false);
|
||||
const error = ref('');
|
||||
const done = ref('');
|
||||
const fieldsOpen = ref(false);
|
||||
const templatesOpen = ref(false);
|
||||
const savingTemplate = ref(false);
|
||||
const templateName = ref('');
|
||||
const bodyEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const history = ref<OutboundSmsLogDto[]>([]);
|
||||
const historyLoading = ref(false);
|
||||
const templates = ref<SmsTemplateDto[]>([]);
|
||||
const templatesLoading = ref(false);
|
||||
|
||||
const preview = computed(() => fill(text.value));
|
||||
const count = computed(() => text.value.length);
|
||||
const parts = computed(() => countSmsParts(text.value));
|
||||
const canSaveTemplate = computed(
|
||||
() =>
|
||||
text.value.trim().length >= 2 &&
|
||||
templateName.value.trim().length >= 1 &&
|
||||
templates.value.length < SMS_TEMPLATE_MAX &&
|
||||
!savingTemplate.value,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -56,8 +77,11 @@ watch(
|
||||
error.value = '';
|
||||
done.value = '';
|
||||
fieldsOpen.value = false;
|
||||
templatesOpen.value = false;
|
||||
templateName.value = '';
|
||||
busy.value = false;
|
||||
void loadHistory();
|
||||
void loadTemplates();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -77,6 +101,18 @@ async function loadHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
templatesLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<SmsTemplateDto[]>('/sms-templates');
|
||||
templates.value = Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
templates.value = [];
|
||||
} finally {
|
||||
templatesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fill(source: string) {
|
||||
const map: Record<string, string> = {
|
||||
نام: props.name,
|
||||
@@ -111,7 +147,49 @@ function insertField(token: string) {
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
fieldsOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleFields() {
|
||||
fieldsOpen.value = !fieldsOpen.value;
|
||||
}
|
||||
|
||||
function toggleTemplates() {
|
||||
templatesOpen.value = !templatesOpen.value;
|
||||
if (templatesOpen.value) void loadTemplates();
|
||||
}
|
||||
|
||||
function applyTemplate(item: SmsTemplateDto) {
|
||||
text.value = item.body;
|
||||
templatesOpen.value = false;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const name = templateName.value.trim().slice(0, SMS_TEMPLATE_NAME_MAX);
|
||||
const body = text.value.trim();
|
||||
if (!canSaveTemplate.value || !name || body.length < 2) return;
|
||||
savingTemplate.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<SmsTemplateDto[]>('/sms-templates', { name, body });
|
||||
templates.value = Array.isArray(data) ? data : [];
|
||||
templateName.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ذخیره پیامک آماده ناموفق بود.');
|
||||
} finally {
|
||||
savingTemplate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(item: SmsTemplateDto) {
|
||||
if (!confirm(`پیامک آماده «${item.name}» حذف شود؟`)) return;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.delete<SmsTemplateDto[]>(`/sms-templates/${item.id}`);
|
||||
templates.value = Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف پیامک آماده ناموفق بود.');
|
||||
}
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
@@ -122,17 +200,27 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
async function send(force = false) {
|
||||
if (!props.endpoint || busy.value || done.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(props.endpoint, { text: text.value });
|
||||
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
|
||||
done.value = 'پیامک ارسال شد.';
|
||||
emit('sent');
|
||||
void loadHistory();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
||||
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
|
||||
if (confirm(msg)) {
|
||||
busy.value = false;
|
||||
await send(true);
|
||||
return;
|
||||
}
|
||||
error.value = msg;
|
||||
} else {
|
||||
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -152,7 +240,19 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||
</p>
|
||||
|
||||
<div class="tools">
|
||||
<button type="button" class="pill" @click="fieldsOpen = !fieldsOpen">فیلدهای پویا</button>
|
||||
<div class="tool-row">
|
||||
<button type="button" class="pill" :class="{ on: fieldsOpen }" @click="toggleFields">
|
||||
فیلدهای پویا
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
:class="{ on: templatesOpen }"
|
||||
@click="toggleTemplates"
|
||||
>
|
||||
پیامکهای آماده
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fieldsOpen" class="fields">
|
||||
<button
|
||||
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
||||
@@ -164,6 +264,55 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="templatesOpen" class="templates">
|
||||
<p v-if="templatesLoading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="!templates.length" class="muted empty">
|
||||
هنوز پیامک آمادهای نیست. متن را بنویسید و ذخیره کنید.
|
||||
</p>
|
||||
<ul v-else class="template-list">
|
||||
<li v-for="item in templates" :key="item.id">
|
||||
<button type="button" class="pick" @click="applyTemplate(item)">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.body }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="del"
|
||||
:aria-label="`حذف ${item.name}`"
|
||||
@click="removeTemplate(item)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tpl-fields" role="group" aria-label="فیلدهای پویا برای متن آماده">
|
||||
<span class="tpl-fields-label">درج فیلد در متن:</span>
|
||||
<button
|
||||
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
||||
:key="`tpl-${item.token}`"
|
||||
type="button"
|
||||
:title="item.label"
|
||||
@click="insertField(item.token)"
|
||||
>
|
||||
<code>{{ item.token }}</code>
|
||||
</button>
|
||||
</div>
|
||||
<form class="save-row" @submit.prevent="saveTemplate">
|
||||
<input
|
||||
v-model="templateName"
|
||||
type="text"
|
||||
:maxlength="SMS_TEMPLATE_NAME_MAX"
|
||||
placeholder="نام پیامک آماده"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" :disabled="!canSaveTemplate">
|
||||
{{ savingTemplate ? '…' : 'ذخیره متن فعلی' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="templates.length >= SMS_TEMPLATE_MAX" class="muted">
|
||||
سقف {{ toFaDigits(SMS_TEMPLATE_MAX) }} قالب پر شده است.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea ref="bodyEl" v-model="text" rows="4" maxlength="500" placeholder="سلام {نام} عزیز" />
|
||||
@@ -238,6 +387,12 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.tool-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.fields button {
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
||||
@@ -253,7 +408,14 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.fields {
|
||||
.pill.on {
|
||||
background: var(--accent-deep);
|
||||
color: #fff;
|
||||
border-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.fields,
|
||||
.templates {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: calc(100% + 0.3rem);
|
||||
@@ -283,6 +445,128 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.template-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
max-height: 11rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.template-list li {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.template-list .pick {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: #f8fafc;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.template-list .pick:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.template-list .pick strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.template-list .pick span {
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-list .del {
|
||||
flex: none;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #b91c1c;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.tpl-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.tpl-fields-label {
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
margin-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
.tpl-fields button {
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
||||
background: #fff;
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tpl-fields code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.save-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.save-row button {
|
||||
flex: none;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-row button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
|
||||
import { LeftOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export type StagePathItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stages: StagePathItem[];
|
||||
value?: string | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
value: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
ariaLabel: 'مراحل',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [id: string];
|
||||
select: [id: string];
|
||||
}>();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
props.stages.findIndex((stage) => stage.id === props.value),
|
||||
);
|
||||
|
||||
const stripStyle = computed(() => ({
|
||||
width: '100%',
|
||||
overflowX: 'auto' as const,
|
||||
paddingBottom: '2px',
|
||||
}));
|
||||
|
||||
const sepStyle = computed(() => ({
|
||||
color: token.value.colorTextQuaternary,
|
||||
fontSize: '10px',
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
function onPick(id: string) {
|
||||
if (props.disabled || props.loading || id === props.value) return;
|
||||
emit('update:value', id);
|
||||
emit('select', id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="listbox"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading || undefined"
|
||||
:style="stripStyle"
|
||||
>
|
||||
<a-flex align="center" :gap="6" wrap="nowrap" :style="{ width: 'max-content', minWidth: '100%' }">
|
||||
<template v-for="(stage, index) in stages" :key="stage.id">
|
||||
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
|
||||
<a-button
|
||||
role="option"
|
||||
:aria-selected="stage.id === value"
|
||||
:type="stage.id === value ? 'primary' : 'default'"
|
||||
:disabled="disabled || loading"
|
||||
:style="{
|
||||
borderRadius: '999px',
|
||||
flexShrink: 0,
|
||||
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
|
||||
opacity: index < currentIndex ? 0.85 : 1,
|
||||
}"
|
||||
@click="onPick(stage.id)"
|
||||
>
|
||||
{{ stage.name }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,8 +18,10 @@ const props = withDefaults(
|
||||
tableKey: string;
|
||||
label?: string;
|
||||
align?: 'start' | 'end';
|
||||
/** فقط آیکون تنظیمات (بدون متن «ستونها») */
|
||||
iconOnly?: boolean;
|
||||
}>(),
|
||||
{ label: 'ستونها', align: 'start' },
|
||||
{ label: 'ستونها', align: 'start', iconOnly: false },
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
@@ -60,11 +62,18 @@ const placement = computed(() =>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
|
||||
<a-button
|
||||
:type="hiddenCount > 0 ? 'primary' : 'default'"
|
||||
:ghost="hiddenCount > 0"
|
||||
:title="iconOnly ? (hiddenCount ? `${label} · ${toFaDigits(hiddenCount)} مخفی` : label) : undefined"
|
||||
:aria-label="label"
|
||||
>
|
||||
<template #icon><SettingOutlined /></template>
|
||||
{{ label }}
|
||||
<template v-if="hiddenCount">
|
||||
· {{ toFaDigits(hiddenCount) }} مخفی
|
||||
<template v-if="!iconOnly">
|
||||
{{ label }}
|
||||
<template v-if="hiddenCount">
|
||||
· {{ toFaDigits(hiddenCount) }} مخفی
|
||||
</template>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
|
||||
const props = defineProps<{
|
||||
users: OrgUserDto[];
|
||||
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
|
||||
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
||||
);
|
||||
|
||||
const teamsQuery = ref('');
|
||||
const accessQuery = ref('');
|
||||
|
||||
const filteredTeams = computed(() => {
|
||||
const q = teamsQuery.value.trim();
|
||||
if (!q) return teams.value;
|
||||
return teams.value.filter((team) =>
|
||||
matchesSearch(q, [
|
||||
team.name,
|
||||
team.description,
|
||||
...team.members.map((m) => m.fullName),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const filteredAccessUsers = computed(() => {
|
||||
const q = accessQuery.value.trim();
|
||||
if (!q) return editableUsers.value;
|
||||
return editableUsers.value.filter((u) =>
|
||||
matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName, accessSummary(u)]),
|
||||
);
|
||||
});
|
||||
|
||||
async function setPriorArchive(userId: string, enabled: boolean) {
|
||||
priorSavingId.value = userId;
|
||||
priorPick.value = '';
|
||||
@@ -383,14 +407,22 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>تیمها</h3>
|
||||
<header class="card-head">
|
||||
<h3>تیمها</h3>
|
||||
<UiInput
|
||||
v-if="teams.length"
|
||||
v-model="teamsQuery"
|
||||
placeholder="جستجوی تیم یا عضو…"
|
||||
/>
|
||||
</header>
|
||||
<UiEmptyState
|
||||
v-if="!loading && !teams.length"
|
||||
title="تیمی تعریف نشده"
|
||||
description="تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
/>
|
||||
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
|
||||
<div v-else class="team-list">
|
||||
<article v-for="t in teams" :key="t.id" class="team">
|
||||
<article v-for="t in filteredTeams" :key="t.id" class="team">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ t.name }}</strong>
|
||||
@@ -504,7 +536,14 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>محدوده داده</h3>
|
||||
<header class="card-head">
|
||||
<h3>محدوده داده</h3>
|
||||
<UiInput
|
||||
v-if="editableUsers.length"
|
||||
v-model="accessQuery"
|
||||
placeholder="جستجوی کاربر…"
|
||||
/>
|
||||
</header>
|
||||
<p class="hint">
|
||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
||||
جدا بگذارید.
|
||||
@@ -514,8 +553,9 @@ onMounted(() => {
|
||||
title="کاربر قابل تنظیم نیست"
|
||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||
/>
|
||||
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
|
||||
<div v-else class="vis-table">
|
||||
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
|
||||
<div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
|
||||
<div class="vis-row">
|
||||
<span>
|
||||
{{ u.fullName }}
|
||||
@@ -567,6 +607,22 @@ onMounted(() => {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.card-head :deep(.field),
|
||||
.card-head :deep(input) {
|
||||
min-width: min(16rem, 100%);
|
||||
max-width: 18rem;
|
||||
}
|
||||
.prior-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -722,5 +778,35 @@ select {
|
||||
.prior-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card-head :deep(.field),
|
||||
.card-head :deep(input) {
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.team .actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vis-row {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.vis-badge {
|
||||
display: inline-block;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type PickUser = { id: string; fullName: string };
|
||||
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
|
||||
type AssigneeGroup = { id: string; name: string; userIds: string[] };
|
||||
type GroupKind = 'role' | 'team';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
users: PickUser[];
|
||||
/** انتخاب یک نقش، همهٔ اعضایش را مسئول میکند */
|
||||
roles?: AssigneeGroup[];
|
||||
/** انتخاب یک تیم، همهٔ اعضایش را مسئول میکند */
|
||||
teams?: AssigneeGroup[];
|
||||
disabled?: boolean;
|
||||
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
||||
min?: number;
|
||||
addLabel?: string;
|
||||
emptyLabel?: string;
|
||||
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمعکردن برچسبها وقتی تعداد زیاد است */
|
||||
selectAll?: boolean;
|
||||
}>(),
|
||||
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
|
||||
{
|
||||
roles: () => [],
|
||||
teams: () => [],
|
||||
disabled: false,
|
||||
min: 0,
|
||||
addLabel: 'افزودن',
|
||||
emptyLabel: 'کسی انتخاب نشده',
|
||||
selectAll: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,32 +39,208 @@ const emit = defineEmits<{
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const options = computed(() =>
|
||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
const pinned = ref(new Set<string>());
|
||||
|
||||
function uniqueIds(ids: string[]) {
|
||||
return [...new Set(ids.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
const catalog = computed(() => {
|
||||
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
|
||||
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
|
||||
for (const group of groups) {
|
||||
const userIds = uniqueIds(group.userIds);
|
||||
if (!userIds.length || !group.id || !group.name) continue;
|
||||
rows.push({
|
||||
token: `${kind}:${group.id}`,
|
||||
name: group.name,
|
||||
userIds,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
push('role', props.roles);
|
||||
push('team', props.teams);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
|
||||
|
||||
function covers(userIds: string[], selected: string[]) {
|
||||
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
|
||||
}
|
||||
|
||||
const shownGroups = computed(() =>
|
||||
catalog.value.filter((group) => {
|
||||
if (!covers(group.userIds, props.modelValue)) return false;
|
||||
if (group.userIds.length >= 2) return true;
|
||||
return pinned.value.has(group.token);
|
||||
}),
|
||||
);
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
const ids = Array.isArray(value) ? value.map(String) : [];
|
||||
const selectValue = computed(() => {
|
||||
const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
|
||||
return [
|
||||
...shownGroups.value.map((group) => group.token),
|
||||
...props.modelValue.filter((id) => !covered.has(id)),
|
||||
];
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
const people = props.users.map((user) => ({
|
||||
value: user.id,
|
||||
label:
|
||||
catalog.value.length && user.orgRoleName
|
||||
? `${user.fullName} — ${user.orgRoleName}`
|
||||
: user.fullName,
|
||||
}));
|
||||
if (!catalog.value.length) return people;
|
||||
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
|
||||
const section = (kind: GroupKind, title: string) => {
|
||||
const items = catalog.value
|
||||
.filter((group) => group.kind === kind)
|
||||
.map((group) => ({
|
||||
value: group.token,
|
||||
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
|
||||
}));
|
||||
if (items.length) grouped.push({ label: title, options: items });
|
||||
};
|
||||
section('role', 'نقشها');
|
||||
section('team', 'تیمها');
|
||||
if (people.length) grouped.push({ label: 'اعضا', options: people });
|
||||
return grouped;
|
||||
});
|
||||
|
||||
function filterOption(input: string, option?: { label?: unknown }) {
|
||||
const label = String(option?.label ?? '');
|
||||
if (!label || !input.trim()) return true;
|
||||
return normalizeSearch(label).includes(normalizeSearch(input));
|
||||
}
|
||||
|
||||
const allPicked = computed(
|
||||
() =>
|
||||
props.users.length > 0 &&
|
||||
props.users.every((user) => props.modelValue.includes(user.id)),
|
||||
);
|
||||
|
||||
const tagLimit = computed(() => {
|
||||
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function tagPlaceholder(omitted: unknown[]) {
|
||||
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
|
||||
const extra = Array.isArray(omitted) ? omitted.length : 0;
|
||||
if (!extra) return '';
|
||||
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue.join('\0'),
|
||||
() => {
|
||||
const next = new Set<string>();
|
||||
for (const token of pinned.value) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group && covers(group.userIds, props.modelValue)) next.add(token);
|
||||
}
|
||||
pinned.value = next;
|
||||
},
|
||||
);
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
const have = new Set(left);
|
||||
return right.every((id) => have.has(id));
|
||||
}
|
||||
|
||||
function commit(ids: string[]) {
|
||||
if (ids.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', ids);
|
||||
emit('change', ids);
|
||||
if (!sameIds(ids, props.modelValue)) emit('change', ids);
|
||||
}
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
const tokens = Array.isArray(value) ? value.map(String) : [];
|
||||
const tokenSet = new Set(tokens);
|
||||
const removedMemberIds = new Set<string>();
|
||||
for (const token of selectValue.value) {
|
||||
if (tokenSet.has(token)) continue;
|
||||
const dropped = catalogByToken.value.get(token);
|
||||
if (!dropped) continue;
|
||||
for (const id of dropped.userIds) removedMemberIds.add(id);
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pins = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group) {
|
||||
pins.add(token);
|
||||
for (const id of group.userIds) {
|
||||
if (removedMemberIds.has(id)) continue;
|
||||
ids.add(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
|
||||
}
|
||||
const next = [...ids];
|
||||
if (next.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
pinned.value = pins;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function pickAll() {
|
||||
if (props.disabled || allPicked.value) return;
|
||||
commit(props.users.map((user) => user.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:value="modelValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
:value="selectValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
:filter-option="filterOption"
|
||||
:max-tag-count="tagLimit"
|
||||
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
|
||||
همه
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-multi.with-all {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-multi.with-all :deep(.ant-select) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-multi.with-all :deep(.ant-btn) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,9 +9,10 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
|
||||
import {
|
||||
ACTIVITY_BOARD_COPY,
|
||||
initialsFromTitle,
|
||||
@@ -41,6 +42,7 @@ const emit = defineEmits<{
|
||||
stagesChanged: [];
|
||||
add: [stageId: string];
|
||||
error: [message: string];
|
||||
notice: [message: string];
|
||||
menuDeal: [activity: ActivityDto, stageId?: string];
|
||||
toggleSelect: [activityId: string];
|
||||
bulkDelete: [];
|
||||
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
|
||||
const overlayDeal = ref<PipelineDeal | null>(null);
|
||||
const overlayAccent = ref<PipelineAccent>('green');
|
||||
const overlayPos = ref({ x: 0, y: 0 });
|
||||
const quickId = ref<string | null>(null);
|
||||
const peekId = ref<string | null>(null);
|
||||
let peekLinger = 0;
|
||||
const moving = ref(false);
|
||||
const stageBusy = ref(false);
|
||||
const localOrder = ref<string[] | null>(null);
|
||||
/** صف جابهجایی تا دراپ پشتسرهم قفل نشود؛ در watchdog هم ریست میشود */
|
||||
let moveChain: Promise<void> = Promise.resolve();
|
||||
let moveWatchdog = 0;
|
||||
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
|
||||
let dragSessionId: string | null = null;
|
||||
let dragVisualTimer = 0;
|
||||
|
||||
function clearDragState() {
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = null;
|
||||
}
|
||||
|
||||
function resetMoveLock() {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moving.value = false;
|
||||
moveChain = Promise.resolve();
|
||||
}
|
||||
|
||||
function armMoveWatchdog() {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moveWatchdog = window.setTimeout(() => {
|
||||
resetMoveLock();
|
||||
}, 12_000);
|
||||
}
|
||||
|
||||
function enqueueMove(task: () => Promise<void>) {
|
||||
const run = moveChain.then(task, task);
|
||||
moveChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
|
||||
open: undefined,
|
||||
@@ -88,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function dateText(item: ActivityDto) {
|
||||
if (!item.dueAt) return 'بدون موعد';
|
||||
const due = new Date(item.dueAt);
|
||||
const days = calendarDaysBetween(due, new Date());
|
||||
const day =
|
||||
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
|
||||
const late =
|
||||
item.status === 'open' &&
|
||||
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
|
||||
return `${late ? 'عقبافتاده · ' : ''}${day} · ${formatClockFa(due)}`;
|
||||
}
|
||||
|
||||
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
|
||||
const assigneeNames = item.assignees?.length
|
||||
@@ -112,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
title: item.title,
|
||||
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
||||
amount: null,
|
||||
date: dateText(item),
|
||||
date:
|
||||
item.status === 'done'
|
||||
? 'انجام شده'
|
||||
: item.dueAt
|
||||
? formatRelativeDayLabel(item.dueAt)
|
||||
: 'بدون موعد',
|
||||
dateIso: item.dueAt ?? undefined,
|
||||
initials: initialsFromTitle(party || item.title),
|
||||
stageId,
|
||||
createdAt: item.createdAt,
|
||||
@@ -121,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
ownerId: item.assignee?.id ?? null,
|
||||
assigneeIds: (item.assignees ?? []).map((a) => a.id),
|
||||
priority: item.priority,
|
||||
activityStatus: item.status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,12 +259,12 @@ function onMenuActivity(id: string) {
|
||||
}
|
||||
|
||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
const sid = findStageOf(deal.id);
|
||||
overlayAccent.value =
|
||||
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
|
||||
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
|
||||
draggingColumnId.value = null;
|
||||
// قفل/صف مانده از درخواست قبلی را باز کن
|
||||
if (moving.value) resetMoveLock();
|
||||
|
||||
dragSessionId = deal.id;
|
||||
// setData / setDragImage باید همزمان با dragstart باشند
|
||||
event.dataTransfer?.setData('text/plain', deal.id);
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
@@ -235,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
event.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
|
||||
const sid = findStageOf(deal.id);
|
||||
overlayAccent.value =
|
||||
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
|
||||
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
|
||||
|
||||
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو میکند
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragVisualTimer = window.setTimeout(() => {
|
||||
if (dragSessionId !== deal.id) return;
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
if (quickId.value) closeQuick();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
const endedId = dragSessionId ?? draggingId.value;
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId && dragSessionId !== endedId) return;
|
||||
if (draggingId.value && draggingId.value !== endedId) return;
|
||||
clearDragState();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onColumnDragStart(id: string) {
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = id;
|
||||
}
|
||||
|
||||
function onColumnDragEnd() {
|
||||
draggingColumnId.value = null;
|
||||
dropStageId.value = null;
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
@@ -255,17 +322,19 @@ function onDragOver(stageId: string, event: DragEvent) {
|
||||
async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||
const fromStageId = findStageOf(activityId);
|
||||
if (!fromStageId) return;
|
||||
const from = columns.value[fromStageId]!;
|
||||
const from = columns.value[fromStageId];
|
||||
const to = columns.value[toStageId];
|
||||
if (!to) return;
|
||||
if (!from || !to) return;
|
||||
const fromIndex = from.items.findIndex((i) => i.id === activityId);
|
||||
const item = from.items[fromIndex];
|
||||
if (!item) return;
|
||||
const toStage = props.stages.find((s) => s.id === toStageId)!;
|
||||
const toStage = props.stages.find((s) => s.id === toStageId);
|
||||
if (!toStage) return;
|
||||
const same = fromStageId === toStageId;
|
||||
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
|
||||
if (same && index == null) return;
|
||||
let insertAt = index == null || index < 0 ? 0 : index;
|
||||
// بدون ایندکس صریح → ته ستون (همتراز با بورد معامله)
|
||||
let insertAt = index == null || index < 0 ? without.length : index;
|
||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||
if (same && insertAt === fromIndex) return;
|
||||
@@ -287,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||
}
|
||||
|
||||
moving.value = true;
|
||||
armMoveWatchdog();
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
||||
stageId: toStageId,
|
||||
position: insertAt,
|
||||
});
|
||||
if (!same && data.status !== toStage.kind) {
|
||||
const hideFromMine = props.params.desk === 'mine';
|
||||
from.items = hideFromMine
|
||||
? prevFrom.filter((row) => row.id !== activityId)
|
||||
: prevFrom.map((row) => (row.id === activityId ? data : row));
|
||||
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
|
||||
to.items = prevTo;
|
||||
to.total = prevToTotal;
|
||||
emit(
|
||||
'notice',
|
||||
'سهم شما ثبت شد. فعالیت وقتی انجامشده میشود که همهٔ مسئولها سهم خود را بزنند.',
|
||||
);
|
||||
emit('changed');
|
||||
return;
|
||||
}
|
||||
const column = same ? from : to;
|
||||
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
||||
emit('changed');
|
||||
@@ -304,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||
}
|
||||
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
||||
} finally {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
|
||||
dropStageId.value = null;
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
||||
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
||||
draggingColumnId.value = null;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
const transfer = event.dataTransfer?.getData('text/plain') || '';
|
||||
const cardId = dragSessionId || draggingId.value;
|
||||
const columnId = draggingColumnId.value;
|
||||
clearDragState();
|
||||
|
||||
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت بهاشتباه reorder ستون نشود
|
||||
const activityId =
|
||||
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
|
||||
if (activityId) {
|
||||
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
|
||||
void enqueueMove(() => moveCard(activityId, stageId, index));
|
||||
return;
|
||||
}
|
||||
if (!raw || moving.value) return;
|
||||
await moveCard(raw, stageId, index);
|
||||
|
||||
const isStageDrag = transfer.startsWith('stage:');
|
||||
if (isStageDrag || columnId) {
|
||||
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOrder(ids: string[]) {
|
||||
@@ -404,7 +497,7 @@ async function onAddStage(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
function activityQuery(id: string) {
|
||||
const query: Record<string, string> = {
|
||||
from: 'desk',
|
||||
nav: crypto.randomUUID(),
|
||||
@@ -418,13 +511,38 @@ function openActivity(id: string) {
|
||||
if (source.typeDefId) query.typeDefId = source.typeDefId;
|
||||
else if (source.type) query.type = source.type;
|
||||
if (source.status) query.status = source.status;
|
||||
if (source.priority) query.priority = source.priority;
|
||||
if (source.assigneeId) query.assigneeId = source.assigneeId;
|
||||
if (source.title) query.title = source.title;
|
||||
if (source.customer) query.customer = source.customer;
|
||||
if (source.due) query.due = source.due;
|
||||
const columnId = findStageOf(id);
|
||||
if (columnId) query.stage = columnId;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query });
|
||||
return query;
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
if (quickId.value === id) {
|
||||
closeQuick();
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(peekLinger);
|
||||
quickId.value = id;
|
||||
peekId.value = id;
|
||||
}
|
||||
|
||||
function closeQuick() {
|
||||
quickId.value = null;
|
||||
const id = peekId.value;
|
||||
window.clearTimeout(peekLinger);
|
||||
peekLinger = window.setTimeout(() => {
|
||||
if (!quickId.value && peekId.value === id) peekId.value = null;
|
||||
}, 650);
|
||||
}
|
||||
|
||||
function openFullActivity(id: string) {
|
||||
quickId.value = null;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -440,23 +558,32 @@ function openActivity(id: string) {
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
:preview-id="quickId"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@detail-deal="openFullActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@column-drag-start="onColumnDragStart"
|
||||
@column-drag-end="onColumnDragEnd"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
<ActivityQuickView
|
||||
:activity-id="quickId"
|
||||
@close="closeQuick"
|
||||
@changed="loadAll()"
|
||||
@full="openFullActivity"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
|
||||
@@ -17,6 +17,8 @@ const props = defineProps<{
|
||||
activity: ActivityDto;
|
||||
saving?: boolean;
|
||||
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<div class="backdrop" aria-hidden="true" />
|
||||
<section
|
||||
class="sheet"
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
|
||||
export type DeskFilterField =
|
||||
| 'title'
|
||||
| 'who'
|
||||
| 'customer'
|
||||
| 'type'
|
||||
| 'priority'
|
||||
| 'when'
|
||||
| 'status';
|
||||
|
||||
const props = defineProps<{
|
||||
/** یک ستون، یا پنل کامل بالای صفحه */
|
||||
@@ -10,11 +17,13 @@ const props = defineProps<{
|
||||
customer: string;
|
||||
due: '' | 'set' | 'unset';
|
||||
typeKey: string;
|
||||
priorityKey: string;
|
||||
statusKey: string;
|
||||
assigneeId: string;
|
||||
showAssignee: boolean;
|
||||
users: Array<{ id: string; fullName: string }>;
|
||||
types: Array<{ key: string; label: string }>;
|
||||
priorities: Array<{ key: string; label: string; color?: string }>;
|
||||
statuses: Array<{ key: string; label: string }>;
|
||||
}>();
|
||||
|
||||
@@ -23,6 +32,7 @@ const emit = defineEmits<{
|
||||
customer: [value: string];
|
||||
due: [value: '' | 'set' | 'unset'];
|
||||
type: [value: string];
|
||||
priority: [value: string];
|
||||
status: [value: string];
|
||||
assignee: [value: string];
|
||||
clear: [];
|
||||
@@ -86,6 +96,7 @@ const hasAny = computed(
|
||||
() =>
|
||||
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
|
||||
props.typeKey !== 'all' ||
|
||||
props.priorityKey !== 'all' ||
|
||||
props.statusKey !== 'all',
|
||||
);
|
||||
|
||||
@@ -101,6 +112,9 @@ const whoLabel = computed(
|
||||
const typeLabel = computed(
|
||||
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
|
||||
);
|
||||
const priorityLabel = computed(
|
||||
() => props.priorities.find((item) => item.key === props.priorityKey)?.label || 'همه',
|
||||
);
|
||||
const statusLabel = computed(
|
||||
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
|
||||
);
|
||||
@@ -144,6 +158,11 @@ function pickType(next: string) {
|
||||
finish();
|
||||
}
|
||||
|
||||
function pickPriority(next: string) {
|
||||
emit('priority', props.priorityKey === next && next !== 'all' ? 'all' : next);
|
||||
finish();
|
||||
}
|
||||
|
||||
function pickStatus(next: string) {
|
||||
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
|
||||
finish();
|
||||
@@ -282,6 +301,36 @@ function pickAssignee(next: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="shows('priority')">
|
||||
<button
|
||||
v-if="field === 'all'"
|
||||
type="button"
|
||||
class="acc"
|
||||
:class="{ on: priorityKey !== 'all' }"
|
||||
@click="toggleSection('priority')"
|
||||
>
|
||||
<span>اولویت</span>
|
||||
<em>{{ priorityLabel }}</em>
|
||||
</button>
|
||||
<div v-if="opened('priority')" class="pick" role="listbox" aria-label="اولویت">
|
||||
<button
|
||||
v-for="opt in priorities"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
:class="{ on: priorityKey === opt.key }"
|
||||
@click="pickPriority(opt.key)"
|
||||
>
|
||||
<span
|
||||
v-if="opt.color && opt.key !== 'all'"
|
||||
class="prio-dot"
|
||||
:style="{ background: opt.color }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="shows('when')">
|
||||
<button
|
||||
v-if="field === 'all'"
|
||||
@@ -434,6 +483,9 @@ function pickAssignee(next: string) {
|
||||
}
|
||||
|
||||
.pick button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-align: start;
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.5rem;
|
||||
@@ -450,6 +502,13 @@ function pickAssignee(next: string) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prio-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
|
||||
const props = defineProps<{
|
||||
dueAt?: string | null;
|
||||
saving?: boolean;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Image as AImage,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { ActivityDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
|
||||
import { noteChips } from '@/lib/lead-form-notes';
|
||||
import {
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
formatJalaliDayClock,
|
||||
formatJalaliNumeric,
|
||||
formatLocalDateTimeInput,
|
||||
parseLocalInput,
|
||||
startOfLocalDay,
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
activityId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
changed: [];
|
||||
full: [id: string];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const rendered = ref(false);
|
||||
const placed = ref(false);
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const frame = ref({ left: 12, top: 12, width: 440, maxHeight: 560, shift: 88 });
|
||||
let hideTimer = 0;
|
||||
let reveal = false;
|
||||
const VIEW_PAD = 16;
|
||||
|
||||
function cardEl(id: string) {
|
||||
return document.querySelector<HTMLElement>(`.ant-card[data-card-id="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
function columnScroller(el: HTMLElement) {
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
|
||||
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function panelExtent() {
|
||||
const content = panelEl.value?.scrollHeight ?? 0;
|
||||
return content > 80 ? content : 328;
|
||||
}
|
||||
|
||||
/** Lift the column so a top-aligned panel fits. The last card may have no room left. */
|
||||
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
|
||||
const overflow = cardTop + height - (vh - VIEW_PAD);
|
||||
if (overflow <= 8) return;
|
||||
const scroller = columnScroller(el);
|
||||
if (!scroller) return;
|
||||
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
||||
const delta = Math.min(overflow, Math.max(0, room));
|
||||
if (delta <= 8) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||
scroller.scrollBy({ top: delta, behavior: motion });
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const id = props.activityId;
|
||||
if (!id) return;
|
||||
const el = cardEl(id);
|
||||
if (!el) return;
|
||||
let rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8) return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const width = Math.min(440, vw - 24);
|
||||
const gap = 10;
|
||||
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
|
||||
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
|
||||
left = Math.max(12, Math.min(left, vw - width - 12));
|
||||
const height = Math.min(panelExtent(), Math.max(160, vh - VIEW_PAD * 2));
|
||||
if (reveal) {
|
||||
reveal = false;
|
||||
liftColumn(el, rect.top, height, vh);
|
||||
rect = el.getBoundingClientRect();
|
||||
}
|
||||
let top = rect.top;
|
||||
const maxTop = vh - VIEW_PAD - height;
|
||||
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
|
||||
if (top < VIEW_PAD) top = VIEW_PAD;
|
||||
const maxHeight = Math.max(160, vh - VIEW_PAD - top);
|
||||
const travel = Math.min(120, Math.round(width * 0.28));
|
||||
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
|
||||
}
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
position: 'fixed' as const,
|
||||
zIndex: 1050,
|
||||
top: `${frame.value.top}px`,
|
||||
left: `${frame.value.left}px`,
|
||||
width: `${frame.value.width}px`,
|
||||
maxHeight: `${frame.value.maxHeight}px`,
|
||||
overflow: 'auto' as const,
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: `${token.value.padding}px`,
|
||||
background: token.value.colorBgElevated,
|
||||
color: token.value.colorText,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadowSecondary,
|
||||
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
|
||||
opacity: placed.value ? 1 : 0,
|
||||
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
|
||||
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
function onViewport() {
|
||||
if (props.activityId) measure();
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!props.activityId || !placed.value) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (panelEl.value?.contains(target)) return;
|
||||
if (commentOpen.value) return;
|
||||
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
|
||||
const card = cardEl(props.activityId);
|
||||
if (card?.contains(target)) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !props.activityId) return;
|
||||
if (commentOpen.value) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onViewport);
|
||||
window.addEventListener('scroll', onViewport, true);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onViewport);
|
||||
window.removeEventListener('scroll', onViewport, true);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.clearTimeout(hideTimer);
|
||||
clearNoteMedia();
|
||||
});
|
||||
|
||||
const activity = ref<ActivityDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const commentOpen = ref(false);
|
||||
const postponeKey = ref('h1');
|
||||
const customDue = ref('');
|
||||
|
||||
type NoteMedia = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: 'image' | 'audio' | 'file';
|
||||
createdAt: string;
|
||||
};
|
||||
const noteMedia = ref<NoteMedia[]>([]);
|
||||
|
||||
function clearNoteMedia() {
|
||||
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
|
||||
noteMedia.value = [];
|
||||
}
|
||||
|
||||
const isClosed = computed(
|
||||
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
||||
);
|
||||
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
|
||||
const canCollaborate = computed(() =>
|
||||
canCollaborateOnActivity(activity.value, auth.user),
|
||||
);
|
||||
const needsEveryone = computed(
|
||||
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
|
||||
);
|
||||
const myShareDone = computed(() => {
|
||||
const mine = auth.user?.id;
|
||||
return Boolean(mine && activity.value?.completedByIds?.includes(mine));
|
||||
});
|
||||
const actionLabel = computed(() => {
|
||||
if (isClosed.value) return 'بازگشایی';
|
||||
if (needsEveryone.value && myShareDone.value) return 'لغو انجام';
|
||||
return 'انجام شده';
|
||||
});
|
||||
|
||||
const createdLabel = computed(() => {
|
||||
const raw = activity.value?.createdAt;
|
||||
if (!raw) return '—';
|
||||
const date = new Date(raw);
|
||||
return `${formatJalaliDay(date)} - ${formatClockFa(date)}`;
|
||||
});
|
||||
|
||||
const thread = computed(() => parseActivityThread(activity.value?.description));
|
||||
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
|
||||
const threadBlocks = computed(() => {
|
||||
const entries = thread.value;
|
||||
if (!entries) return null;
|
||||
return bindThreadMedia(entries, noteMedia.value);
|
||||
});
|
||||
const looseImages = computed(() =>
|
||||
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
|
||||
);
|
||||
|
||||
const noteScrollStyle = computed(() => ({
|
||||
maxHeight: '220px',
|
||||
overflow: 'auto',
|
||||
}));
|
||||
|
||||
const noteBubbleStyle = computed(() => ({
|
||||
padding: '8px 12px',
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: token.value.colorFillAlter,
|
||||
}));
|
||||
|
||||
const postponeBase = computed(() => {
|
||||
const now = new Date();
|
||||
const due = activity.value?.dueAt ? new Date(activity.value.dueAt) : now;
|
||||
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
|
||||
return due;
|
||||
});
|
||||
|
||||
function addMinutes(mins: number) {
|
||||
return new Date(postponeBase.value.getTime() + mins * 60_000);
|
||||
}
|
||||
|
||||
function tomorrowMorning() {
|
||||
const d = startOfLocalDay(new Date());
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(10, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function nextSaturdayMorning() {
|
||||
const d = startOfLocalDay(new Date());
|
||||
const untilSaturday = (6 - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
|
||||
d.setHours(10, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
const postponeChoices = computed(() => {
|
||||
const h1 = addMinutes(60);
|
||||
const morning = tomorrowMorning();
|
||||
const saturday = nextSaturdayMorning();
|
||||
const week = new Date(postponeBase.value.getTime());
|
||||
week.setDate(week.getDate() + 7);
|
||||
return [
|
||||
{ key: 'h1', label: `یک ساعت دیرتر · ${formatClockFa(h1)}`, at: h1 },
|
||||
{ key: 'tomorrow-am', label: `فردا صبح · ${formatClockFa(morning)}`, at: morning },
|
||||
{ key: 'next-saturday', label: `شنبه آینده · ${formatJalaliDayClock(saturday)}`, at: saturday },
|
||||
{ key: 'next-week', label: `هفته آینده · ${formatJalaliDayClock(week)}`, at: week },
|
||||
{ key: 'custom', label: 'زمان دلخواه', at: null },
|
||||
];
|
||||
});
|
||||
|
||||
const postponeReady = computed(() => {
|
||||
if (postponeKey.value === 'custom') return Boolean(parseLocalInput(customDue.value));
|
||||
return postponeChoices.value.some((item) => item.key === postponeKey.value && item.at);
|
||||
});
|
||||
|
||||
function resetDrafts() {
|
||||
commentOpen.value = false;
|
||||
postponeKey.value = 'h1';
|
||||
customDue.value = '';
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
if (Array.isArray(msg)) return msg[0] ?? fallback;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadNoteMedia(row: ActivityDto) {
|
||||
clearNoteMedia();
|
||||
const next: NoteMedia[] = [];
|
||||
for (const image of row.images ?? []) {
|
||||
const mime = image.mimeType.toLowerCase();
|
||||
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
||||
try {
|
||||
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(data);
|
||||
if (props.activityId !== row.id) {
|
||||
URL.revokeObjectURL(url);
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: image.id,
|
||||
name: image.fileName,
|
||||
url,
|
||||
kind,
|
||||
createdAt: image.createdAt,
|
||||
});
|
||||
} catch {
|
||||
/* فایل در دسترس نیست */
|
||||
}
|
||||
}
|
||||
if (props.activityId !== row.id) {
|
||||
for (const item of next) URL.revokeObjectURL(item.url);
|
||||
return;
|
||||
}
|
||||
noteMedia.value = next;
|
||||
await nextTick();
|
||||
measure();
|
||||
}
|
||||
|
||||
async function load(id: string) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id}`);
|
||||
if (props.activityId !== id) return;
|
||||
activity.value = data;
|
||||
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
|
||||
void loadNoteMedia(data);
|
||||
} catch (e) {
|
||||
if (props.activityId === id) {
|
||||
activity.value = null;
|
||||
error.value = apiMessage(e, 'بارگذاری فعالیت ناموفق بود.');
|
||||
}
|
||||
} finally {
|
||||
if (props.activityId === id) {
|
||||
loading.value = false;
|
||||
reveal = true;
|
||||
await nextTick();
|
||||
measure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activityId,
|
||||
async (id, prev) => {
|
||||
if (!id) {
|
||||
activity.value = null;
|
||||
error.value = '';
|
||||
resetDrafts();
|
||||
clearNoteMedia();
|
||||
placed.value = false;
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
if (!props.activityId) rendered.value = false;
|
||||
}, 230);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(hideTimer);
|
||||
if (prev && prev !== id) {
|
||||
resetDrafts();
|
||||
activity.value = null;
|
||||
clearNoteMedia();
|
||||
}
|
||||
if (prev && prev !== id && rendered.value) placed.value = false;
|
||||
rendered.value = true;
|
||||
measure();
|
||||
await nextTick();
|
||||
measure();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (props.activityId === id) placed.value = true;
|
||||
});
|
||||
});
|
||||
void load(id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function runAction() {
|
||||
const id = props.activityId;
|
||||
const row = activity.value;
|
||||
if (!id || !row || saving.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (isClosed.value) {
|
||||
if (!canMutate.value) {
|
||||
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
await api.patch(`/activities/${id}`, { status: 'open' });
|
||||
await load(id);
|
||||
emit('changed');
|
||||
return;
|
||||
}
|
||||
const undo = needsEveryone.value && myShareDone.value;
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${id}/complete`, undefined, {
|
||||
params: undo ? { undo: '1' } : undefined,
|
||||
});
|
||||
if (data.status === 'done') {
|
||||
emit('changed');
|
||||
emit('close');
|
||||
return;
|
||||
}
|
||||
activity.value = data;
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ثبت تغییر ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postponeTo(iso: string) {
|
||||
const id = props.activityId;
|
||||
if (!id || saving.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id}`, { dueAt: iso });
|
||||
await load(id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'تعویق فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
||||
const id = props.activityId;
|
||||
const row = activity.value;
|
||||
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
|
||||
if (!id || !row || !body) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const prev = row.description?.trim();
|
||||
const now = new Date();
|
||||
const author = auth.user?.fullName?.trim();
|
||||
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
|
||||
const entry = `${stamp}\n${body}`;
|
||||
await api.patch(`/activities/${id}`, { description: prev ? `${prev}\n\n${entry}` : entry });
|
||||
commentOpen.value = false;
|
||||
await load(id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ثبت نظر ناموفق بود.');
|
||||
throw e;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPostpone() {
|
||||
if (postponeKey.value === 'custom') {
|
||||
const at = parseLocalInput(customDue.value);
|
||||
if (!at) return;
|
||||
await postponeTo(at.toISOString());
|
||||
return;
|
||||
}
|
||||
const at = postponeChoices.value.find((item) => item.key === postponeKey.value)?.at;
|
||||
if (at) await postponeTo(at.toISOString());
|
||||
}
|
||||
|
||||
function openFull() {
|
||||
if (!props.activityId) return;
|
||||
emit('full', props.activityId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rendered"
|
||||
ref="panelEl"
|
||||
role="dialog"
|
||||
dir="rtl"
|
||||
:aria-label="activity?.title || 'جزئیات فعالیت'"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
|
||||
<a-button type="text" aria-label="بستن" @click="emit('close')">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon :style="{ marginBottom: '12px' }" />
|
||||
<template v-if="activity">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-text type="secondary">ایجاد شده توسط</a-typography-text>
|
||||
<a-typography-text>{{ activity.createdBy?.fullName || '—' }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-text type="secondary">تاریخ ایجاد</a-typography-text>
|
||||
<a-typography-text>{{ createdLabel }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
|
||||
<a-typography-text strong>یادداشت</a-typography-text>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
aria-label="یادداشت جدید"
|
||||
:disabled="!canCollaborate"
|
||||
@click="commentOpen = true"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<div class="note-photos" :style="noteScrollStyle">
|
||||
<a-flex v-if="threadBlocks" vertical :gap="8">
|
||||
<div
|
||||
v-for="(entry, index) in threadBlocks.blocks"
|
||||
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
|
||||
:style="noteBubbleStyle"
|
||||
>
|
||||
<a-flex v-if="entry.dateLabel" justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="entry.author" strong :style="{ color: token.colorPrimary }">
|
||||
{{ entry.author }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
|
||||
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex
|
||||
v-if="entry.media.some((item) => item.kind === 'image')"
|
||||
wrap="wrap"
|
||||
:gap="8"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>
|
||||
<a-image
|
||||
v-for="image in entry.media.filter((item) => item.kind === 'image')"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
|
||||
<a-image :src="image.url" :alt="image.name" :width="72"
|
||||
:height="72" :preview="{ zIndex: 1300 }" />
|
||||
</div>
|
||||
</a-flex>
|
||||
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
|
||||
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
||||
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
|
||||
<a-image
|
||||
v-for="image in looseImages"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
|
||||
</div>
|
||||
|
||||
<a-flex vertical :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-flex align="center" :gap="8">
|
||||
<a-select
|
||||
v-model:value="postponeKey"
|
||||
:disabled="saving || loading || isClosed || !canCollaborate"
|
||||
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
|
||||
:dropdown-style="{ zIndex: 1200 }"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || isClosed || !canCollaborate || !postponeReady"
|
||||
@click="applyPostpone"
|
||||
>
|
||||
تعویق
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<JalaliDateInput
|
||||
v-if="postponeKey === 'custom'"
|
||||
v-model="customDue"
|
||||
mode="datetime"
|
||||
placeholder="تاریخ و ساعت"
|
||||
:disabled="!canCollaborate"
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || (isClosed && !canMutate)"
|
||||
@click="runAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</a-button>
|
||||
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<ActivityCommentSheet
|
||||
v-if="commentOpen && activity"
|
||||
:activity="activity"
|
||||
:saving="saving"
|
||||
:overlay-z-index="1200"
|
||||
:save="submitComment"
|
||||
@close="commentOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-photos :deep(.ant-image) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.note-photos :deep(.ant-image-img) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import type {
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityPrioritySetting,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ACTIVITY_TYPE_ICON_LABELS,
|
||||
newFieldKey,
|
||||
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
type MgrTab = 'types' | 'priorities';
|
||||
const tab = ref<MgrTab>('types');
|
||||
const prioritiesApi = useActivityPriorities();
|
||||
const priorityDraft = ref<ActivityPrioritySetting[]>(
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })),
|
||||
);
|
||||
const prioritySaved = ref('');
|
||||
const priorityDirty = computed(
|
||||
() => JSON.stringify(priorityDraft.value) !== prioritySaved.value,
|
||||
);
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
|
||||
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
||||
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
|
||||
notice.value = '';
|
||||
}
|
||||
|
||||
async function loadPrioritiesTab() {
|
||||
await prioritiesApi.load(true);
|
||||
priorityDraft.value = prioritiesApi.items.value.map((r) => ({ ...r }));
|
||||
prioritySaved.value = JSON.stringify(priorityDraft.value);
|
||||
}
|
||||
|
||||
function switchTab(next: MgrTab) {
|
||||
if (next === tab.value) return;
|
||||
if (tab.value === 'types' && dirty.value && draft.value) {
|
||||
if (!window.confirm('تغییرات ذخیره نشده از بین میرود. ادامه میدهید؟')) return;
|
||||
}
|
||||
if (tab.value === 'priorities' && priorityDirty.value) {
|
||||
if (!window.confirm('تغییرات اولویتها ذخیره نشده از بین میرود. ادامه میدهید؟')) return;
|
||||
}
|
||||
tab.value = next;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
if (next === 'priorities') void loadPrioritiesTab();
|
||||
}
|
||||
|
||||
async function savePriorities() {
|
||||
for (const row of priorityDraft.value) {
|
||||
if (!row.label.trim()) {
|
||||
error.value = 'عنوان همهٔ سطوح اولویت را بنویسید.';
|
||||
return;
|
||||
}
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(row.color)) {
|
||||
error.value = 'رنگ باید به صورت #RRGGBB باشد.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
busy.value = 'prio';
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const { data } = await api.put<ActivityPrioritySetting[]>('/activities/priorities', {
|
||||
items: priorityDraft.value.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.label.trim(),
|
||||
color: r.color,
|
||||
})),
|
||||
});
|
||||
prioritiesApi.setLocal(data);
|
||||
priorityDraft.value = data.map((r) => ({ ...r }));
|
||||
prioritySaved.value = JSON.stringify(priorityDraft.value);
|
||||
notice.value = 'اولویتها ذخیره شد.';
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ذخیرهٔ اولویتها ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function resetPriorities() {
|
||||
if (!window.confirm('عنوان و رنگها به پیشفرض برگردند؟')) return;
|
||||
priorityDraft.value = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
tab.value = 'types';
|
||||
await load(true);
|
||||
await prioritiesApi.load(true);
|
||||
const first = types.value[0];
|
||||
selectedId.value = null;
|
||||
draft.value = null;
|
||||
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
if (tab.value === 'types' && dirty.value && draft.value) {
|
||||
if (!window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
}
|
||||
if (tab.value === 'priorities' && priorityDirty.value) {
|
||||
if (!window.confirm('تغییرات اولویتها ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
}
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
@@ -291,6 +371,28 @@ function close() {
|
||||
<template>
|
||||
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
||||
<div class="mgr">
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: tab === 'types' }"
|
||||
:aria-selected="tab === 'types'"
|
||||
@click="switchTab('types')"
|
||||
>
|
||||
انواع و فیلدها
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: tab === 'priorities' }"
|
||||
:aria-selected="tab === 'priorities'"
|
||||
@click="switchTab('priorities')"
|
||||
>
|
||||
اولویتها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'types'">
|
||||
<p class="hint">
|
||||
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
||||
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
||||
@@ -479,6 +581,43 @@ function close() {
|
||||
</section>
|
||||
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="prio">
|
||||
<p class="hint">
|
||||
پنج سطح اولویت ثابت است؛ فقط عنوان و رنگ را عوض کنید. تغییر عنوان روی همهٔ فعالیتها و کارهای
|
||||
روتین قبلی هم اعمال میشود (کلید داخلی عوض نمیشود).
|
||||
</p>
|
||||
<ul class="prio-list">
|
||||
<li v-for="row in priorityDraft" :key="row.key" class="prio-row">
|
||||
<span class="swatch" :style="{ background: row.color }" aria-hidden="true" />
|
||||
<label class="f grow">
|
||||
<span>عنوان</span>
|
||||
<input v-model="row.label" maxlength="40" />
|
||||
</label>
|
||||
<label class="f color">
|
||||
<span>رنگ</span>
|
||||
<input v-model="row.color" type="color" :aria-label="`رنگ ${row.label}`" />
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="notice" class="notice">{{ notice }}</p>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="!priorityDirty || Boolean(busy)"
|
||||
:loading="busy === 'prio'"
|
||||
@click="savePriorities"
|
||||
>
|
||||
ذخیرهٔ اولویتها
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="Boolean(busy)" @click="resetPriorities">
|
||||
بازگشت به پیشفرض
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
@@ -489,6 +628,78 @@ function close() {
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tabs button.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prio {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.prio-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.prio-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.prio-row .grow {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.prio-row .color input[type='color'] {
|
||||
width: 3rem;
|
||||
height: 2.1rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Collapse as ACollapse,
|
||||
CollapsePanel as ACollapsePanel,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
ScheduleOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
startOfLocalDay,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
|
||||
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const STATUS_LABELS: Record<ActivityStatus, string> = {
|
||||
open: 'باز',
|
||||
done: 'انجامشده',
|
||||
canceled: 'لغو شده',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
|
||||
call: PhoneOutlined,
|
||||
meeting: TeamOutlined,
|
||||
task: CheckCircleOutlined,
|
||||
note: ScheduleOutlined,
|
||||
};
|
||||
|
||||
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
dealId: string;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealTitle?: string;
|
||||
}>(),
|
||||
{
|
||||
personId: null,
|
||||
companyId: null,
|
||||
dealTitle: '',
|
||||
},
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const items = ref<ActivityDto[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const closedKeys = ref<string[]>([]);
|
||||
|
||||
function sortByDue(a: ActivityDto, b: ActivityDto) {
|
||||
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
}
|
||||
|
||||
const planned = computed(() =>
|
||||
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
|
||||
);
|
||||
const closed = computed(() =>
|
||||
items.value
|
||||
.filter((a) => a.status !== 'open')
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
|
||||
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
|
||||
return tb.localeCompare(ta);
|
||||
}),
|
||||
);
|
||||
|
||||
function dueTone(dueAt: string | null): DueTone {
|
||||
if (!dueAt) return 'none';
|
||||
const due = new Date(dueAt);
|
||||
if (Number.isNaN(due.getTime())) return 'none';
|
||||
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
|
||||
if (days < 0) return 'overdue';
|
||||
if (days === 0) return 'today';
|
||||
if (days <= 2) return 'soon';
|
||||
return 'later';
|
||||
}
|
||||
|
||||
function dueLabel(dueAt: string | null) {
|
||||
if (!dueAt) return 'بدون موعد';
|
||||
const d = new Date(dueAt);
|
||||
if (Number.isNaN(d.getTime())) return 'بدون موعد';
|
||||
const tone = dueTone(dueAt);
|
||||
if (tone === 'overdue') {
|
||||
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
|
||||
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
|
||||
}
|
||||
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
|
||||
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
function dueTagColor(tone: DueTone) {
|
||||
if (tone === 'overdue') return 'error';
|
||||
if (tone === 'today') return 'warning';
|
||||
if (tone === 'soon') return 'processing';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function rowStyle(tone: DueTone, closedRow = false) {
|
||||
const base = {
|
||||
width: '100%',
|
||||
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
background: token.value.colorBgContainer,
|
||||
cursor: 'pointer' as const,
|
||||
opacity: closedRow ? 0.78 : 1,
|
||||
};
|
||||
if (closedRow) {
|
||||
return { ...base, background: token.value.colorFillAlter };
|
||||
}
|
||||
if (tone === 'overdue') {
|
||||
return {
|
||||
...base,
|
||||
borderColor: token.value.colorErrorBorder,
|
||||
background: token.value.colorErrorBg,
|
||||
};
|
||||
}
|
||||
if (tone === 'today') {
|
||||
return {
|
||||
...base,
|
||||
borderColor: token.value.colorWarningBorder,
|
||||
background: token.value.colorWarningBg,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function avatarStyle(tone: DueTone) {
|
||||
if (tone === 'overdue') {
|
||||
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
|
||||
}
|
||||
if (tone === 'today') {
|
||||
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
|
||||
}
|
||||
return {
|
||||
backgroundColor: token.value.colorPrimaryBg,
|
||||
color: token.value.colorPrimary,
|
||||
};
|
||||
}
|
||||
|
||||
function typeLabel(item: ActivityDto) {
|
||||
const type = item.type as ActivityType;
|
||||
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
|
||||
}
|
||||
|
||||
function statusLabel(status: ActivityDto['status']) {
|
||||
return STATUS_LABELS[status as ActivityStatus];
|
||||
}
|
||||
|
||||
function typeIcon(type: ActivityDto['type']) {
|
||||
return TYPE_ICONS[type as ActivityType];
|
||||
}
|
||||
|
||||
function newActivityQuery() {
|
||||
const query: Record<string, string> = { dealId: props.dealId };
|
||||
if (props.personId) query.personId = props.personId;
|
||||
if (props.companyId) query.companyId = props.companyId;
|
||||
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
|
||||
return query;
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
void router.push({ name: 'activity-new', query: newActivityQuery() });
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
void router.push({ name: 'activity-detail', params: { id } });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.dealId) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: { filter: 'all', dealId: props.dealId, limit: 50 },
|
||||
});
|
||||
items.value = data.items ?? [];
|
||||
} catch {
|
||||
error.value = 'بارگذاری فعالیتهای معامله ممکن نشد.';
|
||||
items.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.dealId,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<a-space :size="8">
|
||||
<CalendarOutlined :style="{ color: token.colorPrimary }" />
|
||||
<span>فعالیتهای برنامهریزیشده</span>
|
||||
<a-tag v-if="planned.length" color="processing" :bordered="false">
|
||||
{{ toFaDigits(planned.length) }} باز
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن فعالیت
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginBottom: `${token.margin}px` }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !planned.length"
|
||||
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
|
||||
:style="{ paddingBlock: `${token.paddingLG}px` }"
|
||||
>
|
||||
<template #description>
|
||||
<a-flex vertical align="center" :gap="token.marginXS">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
هنوز فعالیتی برای این معامله نیست
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph
|
||||
type="secondary"
|
||||
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
|
||||
>
|
||||
یک تماس، جلسه یا پیگیری زمانبندی کن تا معامله از دست نرود.
|
||||
</a-typography-paragraph>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-button type="primary" @click="goCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن اولین فعالیت
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
|
||||
<a-flex
|
||||
v-for="item in planned"
|
||||
:key="item.id"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
wrap="wrap"
|
||||
:style="rowStyle(dueTone(item.dueAt))"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goDetail(item.id)"
|
||||
@keydown.enter="goDetail(item.id)"
|
||||
>
|
||||
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
|
||||
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
|
||||
<template #icon>
|
||||
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
|
||||
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
|
||||
<component :is="typeIcon(item.type)" v-else />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||||
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
|
||||
{{ item.title }}
|
||||
</a-typography-text>
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
|
||||
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
|
||||
{{ dueLabel(item.dueAt) }}
|
||||
</a-tag>
|
||||
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
|
||||
{{ item.assignee.fullName }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<template v-if="closed.length">
|
||||
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
|
||||
<a-collapse
|
||||
v-model:activeKey="closedKeys"
|
||||
ghost
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: 'transparent',
|
||||
marginTop: planned.length ? 0 : `${token.marginSM}px`,
|
||||
}"
|
||||
>
|
||||
<a-collapse-panel key="closed">
|
||||
<template #header>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(closed.length) }} فعالیت انجامشده یا لغو شده
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<a-flex vertical :gap="token.marginSM">
|
||||
<a-flex
|
||||
v-for="item in closed"
|
||||
:key="item.id"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
wrap="wrap"
|
||||
:style="rowStyle('none', true)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goDetail(item.id)"
|
||||
@keydown.enter="goDetail(item.id)"
|
||||
>
|
||||
<a-flex
|
||||
align="center"
|
||||
:gap="token.marginSM"
|
||||
:style="{ minWidth: 0, flex: '1 1 220px' }"
|
||||
>
|
||||
<a-avatar
|
||||
:size="40"
|
||||
:style="{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="typeIcon(item.type)" />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||||
<a-typography-text :ellipsis="{ tooltip: item.title }">
|
||||
{{ item.title }}
|
||||
</a-typography-text>
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
|
||||
<a-typography-text type="secondary">
|
||||
{{ dueLabel(item.dueAt) }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { hasSectionPermission } from '@/lib/section-permissions';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||
@@ -25,9 +27,13 @@ type BirthdayMonth = {
|
||||
items: BirthdayItem[];
|
||||
};
|
||||
|
||||
const auth = useAuthStore();
|
||||
const data = ref<BirthdayMonth | null>(null);
|
||||
const listOpen = ref(false);
|
||||
const smsTarget = ref<BirthdayItem | null>(null);
|
||||
const canSeeCustomers = computed(() =>
|
||||
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
|
||||
);
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<number, BirthdayItem[]>();
|
||||
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!canSeeCustomers.value) {
|
||||
data.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
|
||||
data.value = body.total > 0 ? body : null;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
FlagFilled,
|
||||
LeftOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
@@ -18,10 +19,12 @@ import {
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
|
||||
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||
import type { ActivityPriority } from '@mento/shared';
|
||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -31,6 +34,9 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
peeking?: boolean;
|
||||
/** پنل کناری همین کارت باز است؛ فلش را برمیگرداند. جدا از هایلایتِ بعد از بستن. */
|
||||
previewing?: boolean;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY },
|
||||
);
|
||||
@@ -39,6 +45,7 @@ const emit = defineEmits<{
|
||||
dragStart: [deal: PipelineDeal, event: DragEvent];
|
||||
dragEnd: [];
|
||||
open: [dealId: string];
|
||||
detail: [dealId: string];
|
||||
menu: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -47,6 +54,11 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const activityOpen = ref(false);
|
||||
const highlighted = computed(
|
||||
() => props.selected || props.peeking || activityOpen.value,
|
||||
);
|
||||
|
||||
const showAmount = computed(
|
||||
() => !props.copy.hideAmount && props.deal.amount != null,
|
||||
);
|
||||
@@ -63,26 +75,97 @@ const extraAssignees = computed(() =>
|
||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||
);
|
||||
const showTrash = computed(() => props.selectMode && props.selected);
|
||||
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
||||
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
||||
high: { label: 'زیاد', color: '#ea580c' },
|
||||
medium: { label: 'متوسط', color: '#eab308' },
|
||||
low: { label: 'کم', color: '#16a34a' },
|
||||
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
||||
};
|
||||
const priorityFlag = computed(() =>
|
||||
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
||||
);
|
||||
const dateHint = computed(() => {
|
||||
if (!props.deal.dateIso) return props.deal.date;
|
||||
const d = new Date(props.deal.dateIso);
|
||||
if (Number.isNaN(d.getTime())) return props.deal.date;
|
||||
return formatJalaliNumeric(d);
|
||||
});
|
||||
const activityPriorities = useActivityPriorities();
|
||||
onMounted(() => {
|
||||
void activityPriorities.load();
|
||||
});
|
||||
const priorityFlag = computed(() => {
|
||||
const key = props.deal.priority;
|
||||
if (!key) return null;
|
||||
return {
|
||||
label: activityPriorities.labelOf(key),
|
||||
color: activityPriorities.colorOf(key),
|
||||
};
|
||||
});
|
||||
|
||||
function onTitleClick() {
|
||||
if (props.selectMode) {
|
||||
emit('toggleSelect', props.deal.id);
|
||||
return;
|
||||
}
|
||||
if (props.copy.quickView) emit('detail', props.deal.id);
|
||||
else emit('open', props.deal.id);
|
||||
}
|
||||
|
||||
/** موعد: (فقط فعالیت) انجامشده سبز · عقبافتاده قرمز · امروز زرد · آینده/بیموعد خاکستری */
|
||||
const dueTone = computed<'done' | 'overdue' | 'today' | 'upcoming' | 'none'>(() => {
|
||||
if (props.copy.quickView && props.deal.activityStatus === 'done') return 'done';
|
||||
if (!props.deal.dateIso) return 'none';
|
||||
const due = new Date(props.deal.dateIso);
|
||||
if (Number.isNaN(due.getTime())) return 'none';
|
||||
const days = calendarDaysBetween(new Date(), due);
|
||||
if (days < 0) return 'overdue';
|
||||
if (days === 0) return 'today';
|
||||
return 'upcoming';
|
||||
});
|
||||
|
||||
const dueToneColor = computed(() => {
|
||||
if (dueTone.value === 'done') return token.value.colorSuccess;
|
||||
if (dueTone.value === 'overdue') return token.value.colorError;
|
||||
if (dueTone.value === 'today') return token.value.colorWarning;
|
||||
return token.value.colorTextSecondary;
|
||||
});
|
||||
|
||||
const chevronBtnStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: `1.5px solid ${dueToneColor.value}`,
|
||||
borderRadius: '50%',
|
||||
background: 'transparent',
|
||||
color: dueToneColor.value,
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
const chevronIconStyle = computed(() => ({
|
||||
fontSize: '10px',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
lineHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: props.previewing ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s ease',
|
||||
}));
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||
opacity: props.dragging ? 0.4 : 1,
|
||||
transform: props.dragging ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو میکند؛ فقط سایه
|
||||
opacity: props.dragging ? 0.55 : 1,
|
||||
transition: 'opacity 120ms ease, box-shadow 120ms ease',
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||
position: highlighted.value ? 'relative' : undefined,
|
||||
zIndex: highlighted.value ? 2 : undefined,
|
||||
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: highlighted.value
|
||||
? `0 0 0 2px ${token.value.colorPrimary}`
|
||||
: props.dragging
|
||||
? token.value.boxShadow
|
||||
: token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -124,21 +207,29 @@ const cardStyle = computed(() => ({
|
||||
</template>
|
||||
</a-button>
|
||||
<div :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
|
||||
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
|
||||
</a-tooltip>
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="onTitleClick"
|
||||
@dblclick.stop
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-text
|
||||
v-if="deal.description"
|
||||
type="secondary"
|
||||
@@ -193,28 +284,75 @@ const cardStyle = computed(() => ({
|
||||
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
|
||||
+{{ extraAssignees }}
|
||||
</a-tag>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
||||
{{ deal.date }}
|
||||
</a-typography-text>
|
||||
<a-tooltip :title="dateHint">
|
||||
<a-typography-text
|
||||
:type="dueTone === 'none' ? 'secondary' : undefined"
|
||||
:style="{
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
color: dueTone !== 'none' ? dueToneColor : undefined,
|
||||
}"
|
||||
>
|
||||
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
||||
{{ deal.date }}
|
||||
</a-typography-text>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
<a-typography-text
|
||||
v-if="showAmount"
|
||||
strong
|
||||
dir="ltr"
|
||||
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
|
||||
>
|
||||
{{ money.number }}
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ money.unit }}
|
||||
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
|
||||
<a-typography-text
|
||||
v-if="showAmount"
|
||||
strong
|
||||
dir="ltr"
|
||||
:style="{ margin: 0, whiteSpace: 'nowrap' }"
|
||||
>
|
||||
{{ money.number }}
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
<button
|
||||
v-if="copy.quickView && !selectMode"
|
||||
type="button"
|
||||
class="deal-card-peek"
|
||||
:aria-label="previewing ? 'بستن پیشنمایش' : 'پیشنمایش فعالیت'"
|
||||
:aria-expanded="previewing"
|
||||
:style="chevronBtnStyle"
|
||||
@click.stop="emit('open', deal.id)"
|
||||
>
|
||||
<LeftOutlined :style="chevronIconStyle" />
|
||||
</button>
|
||||
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span
|
||||
v-if="copy.quickActivity && !selectMode"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<DealQuickActivityPopover
|
||||
:deal="deal"
|
||||
@update:open="activityOpen = $event"
|
||||
/>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* وسطچین دقیق آیکون داخل دایره (anticon پیشفرض vertical-align دارد) */
|
||||
.deal-card-peek :deep(.anticon) {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.deal-card-peek :deep(.anticon > svg) {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Badge as ABadge,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
message,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CloseOutlined,
|
||||
FormOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
ActivityType,
|
||||
CreateActivityRequest,
|
||||
DealActivityCue,
|
||||
DealActivitySummaryDto,
|
||||
} from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
formatLocalDateInput,
|
||||
formatRelativeDayLabel,
|
||||
formatClockFa,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import type { PipelineDeal } from '@/lib/pipeline-board';
|
||||
|
||||
const { Title: ATypographyTitle, Link: ATypographyLink, Text: ATypographyText } =
|
||||
Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
deal: PipelineDeal;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
type PanelMode = 'list' | 'create';
|
||||
|
||||
const open = ref(false);
|
||||
const rendered = ref(false);
|
||||
const placed = ref(false);
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
|
||||
let hideTimer = 0;
|
||||
let reveal = false;
|
||||
const VIEW_PAD = 16;
|
||||
const PREVIEW_LIMIT = 3;
|
||||
|
||||
const mode = ref<PanelMode>('create');
|
||||
const listExpanded = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const title = ref('');
|
||||
const type = ref<ActivityType>('task');
|
||||
const dueDate = ref('');
|
||||
const dueTime = ref('');
|
||||
const localCue = ref<DealActivityCue | null>(null);
|
||||
const localSummaries = ref<DealActivitySummaryDto[] | null>(null);
|
||||
|
||||
const summaries = computed(
|
||||
() => localSummaries.value ?? props.deal.activitySummaries ?? [],
|
||||
);
|
||||
|
||||
const activityCue = computed<DealActivityCue>(
|
||||
() => localCue.value ?? props.deal.activityCue ?? 'none',
|
||||
);
|
||||
|
||||
const openCount = computed(
|
||||
() => summaries.value.filter((item) => item.status === 'open').length,
|
||||
);
|
||||
|
||||
const visibleSummaries = computed(() =>
|
||||
listExpanded.value ? summaries.value : summaries.value.slice(0, PREVIEW_LIMIT),
|
||||
);
|
||||
|
||||
const hiddenCount = computed(() =>
|
||||
Math.max(0, summaries.value.length - PREVIEW_LIMIT),
|
||||
);
|
||||
|
||||
const cueMeta = computed(() => {
|
||||
switch (activityCue.value) {
|
||||
case 'overdue':
|
||||
return { color: token.value.colorError, title: 'فعالیت عقبافتاده' };
|
||||
case 'done':
|
||||
return { color: token.value.colorSuccess, title: 'فعالیت انجامشده' };
|
||||
case 'none':
|
||||
return { color: token.value.colorWarning, title: 'بدون فعالیت — افزودن' };
|
||||
default:
|
||||
return { color: token.value.colorTextSecondary, title: 'فعالیتهای معامله' };
|
||||
}
|
||||
});
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
|
||||
.filter(([value]) => value !== 'note')
|
||||
.map(([value, label]) => ({ value, label })),
|
||||
);
|
||||
|
||||
const fieldItemStyle = computed(() => ({
|
||||
marginBottom: 0,
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
const panelTitle = computed(() =>
|
||||
mode.value === 'list' ? 'فعالیتهای معامله' : 'ایجاد فعالیت سریع',
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.deal.activityCue, props.deal.activitySummaries] as const,
|
||||
() => {
|
||||
localCue.value = null;
|
||||
localSummaries.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
title.value = `پیگیری: ${props.deal.title}`;
|
||||
type.value = 'task';
|
||||
dueDate.value = formatLocalDateInput(new Date());
|
||||
dueTime.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function cueDotColor(cue: Exclude<DealActivityCue, 'none'>) {
|
||||
if (cue === 'overdue') return token.value.colorError;
|
||||
if (cue === 'done') return token.value.colorSuccess;
|
||||
return token.value.colorTextSecondary;
|
||||
}
|
||||
|
||||
function activityWhen(item: DealActivitySummaryDto) {
|
||||
if (!item.dueAt) return 'بدون موعد';
|
||||
const d = new Date(item.dueAt);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const day = formatRelativeDayLabel(item.dueAt);
|
||||
return `${day} · ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
function cardEl() {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function columnScroller(el: HTMLElement) {
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
|
||||
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function panelExtent() {
|
||||
const content = panelEl.value?.scrollHeight ?? 0;
|
||||
return content > 80 ? content : 360;
|
||||
}
|
||||
|
||||
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
|
||||
const overflow = cardTop + height - (vh - VIEW_PAD);
|
||||
if (overflow <= 8) return;
|
||||
const scroller = columnScroller(el);
|
||||
if (!scroller) return;
|
||||
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
||||
const delta = Math.min(overflow, Math.max(0, room));
|
||||
if (delta <= 8) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
? 'auto'
|
||||
: 'smooth';
|
||||
scroller.scrollBy({ top: delta, behavior: motion });
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const el = cardEl();
|
||||
if (!el) return;
|
||||
let rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8) return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const width = Math.min(360, vw - 24);
|
||||
const gap = 10;
|
||||
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
|
||||
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
|
||||
left = Math.max(12, Math.min(left, vw - width - 12));
|
||||
const height = Math.min(panelExtent(), Math.max(200, vh - VIEW_PAD * 2));
|
||||
if (reveal) {
|
||||
reveal = false;
|
||||
liftColumn(el, rect.top, height, vh);
|
||||
rect = el.getBoundingClientRect();
|
||||
}
|
||||
let top = rect.top;
|
||||
const maxTop = vh - VIEW_PAD - height;
|
||||
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
|
||||
if (top < VIEW_PAD) top = VIEW_PAD;
|
||||
const maxHeight = Math.max(200, vh - VIEW_PAD - top);
|
||||
const travel = Math.min(100, Math.round(width * 0.28));
|
||||
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
|
||||
}
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
position: 'fixed' as const,
|
||||
zIndex: 1050,
|
||||
top: `${frame.value.top}px`,
|
||||
left: `${frame.value.left}px`,
|
||||
width: `${frame.value.width}px`,
|
||||
maxHeight: `${frame.value.maxHeight}px`,
|
||||
overflow: 'auto' as const,
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: `${token.value.padding}px`,
|
||||
background: token.value.colorBgElevated,
|
||||
color: token.value.colorText,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadowSecondary,
|
||||
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
|
||||
opacity: placed.value ? 1 : 0,
|
||||
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
|
||||
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return;
|
||||
open.value = next;
|
||||
emit('update:open', next);
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (props.disabled) return;
|
||||
setOpen(!open.value);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
mode.value = 'create';
|
||||
resetForm();
|
||||
void nextTick().then(() => measure());
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
mode.value = 'list';
|
||||
error.value = '';
|
||||
void nextTick().then(() => measure());
|
||||
}
|
||||
|
||||
function goActivity(id: string) {
|
||||
close();
|
||||
void router.push({ name: 'activity-detail', params: { id } });
|
||||
}
|
||||
|
||||
function onViewport() {
|
||||
if (open.value) measure();
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!open.value || !placed.value) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (panelEl.value?.contains(target)) return;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('.ant-select-dropdown, #jalali-picker, .layer')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const card = cardEl();
|
||||
if (card?.contains(target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open.value) close();
|
||||
}
|
||||
|
||||
watch(open, async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
placed.value = false;
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
if (!open.value) rendered.value = false;
|
||||
}, 230);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(hideTimer);
|
||||
listExpanded.value = false;
|
||||
mode.value = summaries.value.length ? 'list' : 'create';
|
||||
resetForm();
|
||||
rendered.value = true;
|
||||
reveal = true;
|
||||
measure();
|
||||
await nextTick();
|
||||
measure();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (open.value) {
|
||||
placed.value = true;
|
||||
void nextTick().then(() => measure());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onViewport);
|
||||
window.addEventListener('scroll', onViewport, true);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onViewport);
|
||||
window.removeEventListener('scroll', onViewport, true);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.clearTimeout(hideTimer);
|
||||
});
|
||||
|
||||
function dueAtIso(): string | null {
|
||||
if (!dueDate.value) return null;
|
||||
const time = dueTime.value.trim() || '10:30';
|
||||
const due = new Date(`${dueDate.value}T${time}:00`);
|
||||
return Number.isNaN(due.getTime()) ? null : due.toISOString();
|
||||
}
|
||||
|
||||
function fullFormQuery() {
|
||||
const query: Record<string, string> = { dealId: props.deal.id };
|
||||
if (props.deal.personId) query.personId = props.deal.personId;
|
||||
if (props.deal.companyId) query.companyId = props.deal.companyId;
|
||||
if (title.value.trim()) query.title = title.value.trim();
|
||||
if (type.value) query.type = type.value;
|
||||
if (dueAtIso()) query.dueAt = dueAtIso()!;
|
||||
return query;
|
||||
}
|
||||
|
||||
function goFullForm() {
|
||||
close();
|
||||
void router.push({ name: 'activity-new', query: fullFormQuery() });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
if (title.value.trim().length < 2) {
|
||||
error.value = 'عنوان فعالیت را بنویسید.';
|
||||
return;
|
||||
}
|
||||
const dueAt = dueAtIso();
|
||||
if (!dueDate.value || !dueAt) {
|
||||
error.value = 'تاریخ فعالیت را مشخص کنید.';
|
||||
return;
|
||||
}
|
||||
const body: CreateActivityRequest = {
|
||||
title: title.value.trim(),
|
||||
type: type.value,
|
||||
dueAt,
|
||||
dealId: props.deal.id,
|
||||
personId: props.deal.personId || null,
|
||||
companyId: props.deal.companyId || null,
|
||||
assigneeId: props.deal.ownerId || null,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data } = await api.post<ActivityDto>('/activities', body);
|
||||
message.success('فعالیت ثبت شد');
|
||||
const dueMs = new Date(dueAt).getTime();
|
||||
const cue: Exclude<DealActivityCue, 'none'> =
|
||||
Number.isFinite(dueMs) && dueMs < Date.now() ? 'overdue' : 'upcoming';
|
||||
const created: DealActivitySummaryDto = {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
status: data.status === 'done' ? 'done' : 'open',
|
||||
dueAt: data.dueAt,
|
||||
cue,
|
||||
};
|
||||
const next = [created, ...summaries.value.filter((item) => item.id !== created.id)];
|
||||
localSummaries.value = next;
|
||||
localCue.value =
|
||||
next.some((item) => item.cue === 'overdue')
|
||||
? 'overdue'
|
||||
: next.some((item) => item.status === 'open')
|
||||
? 'upcoming'
|
||||
: next.some((item) => item.cue === 'done')
|
||||
? 'done'
|
||||
: 'none';
|
||||
mode.value = 'list';
|
||||
void nextTick().then(() => measure());
|
||||
} catch {
|
||||
error.value = 'ثبت فعالیت ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tooltip :title="cueMeta.title">
|
||||
<a-badge
|
||||
:count="openCount"
|
||||
:offset="[-2, 2]"
|
||||
:number-style="{ fontSize: '10px', minWidth: '16px', height: '16px', lineHeight: '16px' }"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
aria-label="فعالیتهای معامله"
|
||||
:aria-expanded="open"
|
||||
:style="{
|
||||
color: open ? token.colorPrimary : cueMeta.color,
|
||||
width: '28px',
|
||||
minWidth: '28px',
|
||||
height: '28px',
|
||||
padding: 0,
|
||||
}"
|
||||
@click.stop="toggleOpen"
|
||||
@mousedown.stop
|
||||
@dragstart.stop.prevent
|
||||
>
|
||||
<template #icon><CalendarOutlined /></template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-tooltip>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rendered"
|
||||
ref="panelEl"
|
||||
role="dialog"
|
||||
dir="rtl"
|
||||
:aria-label="`${panelTitle} برای ${deal.title}`"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">{{ panelTitle }}</a-typography-title>
|
||||
<a-space :size="4">
|
||||
<a-button
|
||||
v-if="mode === 'list'"
|
||||
type="text"
|
||||
aria-label="فعالیت جدید"
|
||||
@click="openCreate"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
<a-button type="text" aria-label="بستن" @click="close">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<template v-if="mode === 'list'">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-card
|
||||
v-for="item in visibleSummaries"
|
||||
:key="item.id"
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="{ padding: '10px 12px' }"
|
||||
@click="goActivity(item.id)"
|
||||
>
|
||||
<a-flex align="flex-start" :gap="10">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
marginTop: '6px',
|
||||
borderRadius: '999px',
|
||||
background: cueDotColor(item.cue),
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
<div :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text strong :style="{ display: 'block' }">
|
||||
{{ item.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ ACTIVITY_TYPE_LABELS[item.type] || item.type }}
|
||||
·
|
||||
{{ activityWhen(item) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-typography-link
|
||||
v-if="!listExpanded && hiddenCount > 0"
|
||||
@click="listExpanded = true"
|
||||
>
|
||||
مشاهده همه ({{ toFaDigits(summaries.length) }})
|
||||
</a-typography-link>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-flex v-else vertical :gap="token.marginSM">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-form-item label="عنوان" :style="fieldItemStyle">
|
||||
<a-input v-model:value="title" placeholder="عنوان فعالیت" allow-clear />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نوع" :style="fieldItemStyle">
|
||||
<a-select v-model:value="type" :options="typeOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 12]" align="top">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="تاریخ" :style="fieldItemStyle">
|
||||
<JalaliDateInput v-model="dueDate" placeholder="تاریخ" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="ساعت" :style="fieldItemStyle">
|
||||
<JalaliDateInput
|
||||
v-model="dueTime"
|
||||
mode="time"
|
||||
placeholder="اختیاری"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
|
||||
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-space :size="8">
|
||||
<a-typography-link v-if="summaries.length" @click="backToList">
|
||||
بازگشت به لیست
|
||||
</a-typography-link>
|
||||
<a-typography-link @click="goFullForm">
|
||||
<FormOutlined />
|
||||
فرم کامل
|
||||
</a-typography-link>
|
||||
</a-space>
|
||||
<a-space :size="8">
|
||||
<a-button :disabled="saving" @click="summaries.length ? backToList() : close()">
|
||||
انصراف
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -21,8 +21,10 @@ withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
previewId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -32,6 +34,7 @@ const emit = defineEmits<{
|
||||
drop: [stageId: string, event: DragEvent, index?: number];
|
||||
addDeal: [stageId: string];
|
||||
openDeal: [dealId: string];
|
||||
detailDeal: [dealId: string];
|
||||
menuDeal: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -109,6 +112,8 @@ function submitAdd() {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
:preview-id="previewId"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === stages.length - 1"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@@ -117,6 +122,7 @@ function submitAdd() {
|
||||
@drop="(id, e, index) => emit('drop', id, e, index)"
|
||||
@add-deal="emit('addDeal', $event)"
|
||||
@open-deal="emit('openDeal', $event)"
|
||||
@detail-deal="emit('detailDeal', $event)"
|
||||
@menu-deal="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
|
||||
@@ -26,8 +26,10 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
previewId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,6 +39,7 @@ const emit = defineEmits<{
|
||||
drop: [stageId: string, event: DragEvent, index?: number];
|
||||
addDeal: [stageId: string];
|
||||
openDeal: [dealId: string];
|
||||
detailDeal: [dealId: string];
|
||||
menuDeal: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
:peeking="peekId === deal.id"
|
||||
:previewing="previewId === deal.id"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
@detail="emit('detailDeal', $event)"
|
||||
@menu="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import type {
|
||||
PipelineAccent,
|
||||
PipelineDeal,
|
||||
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
|
||||
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال');
|
||||
}
|
||||
|
||||
function dateHint(deal: PipelineDeal) {
|
||||
if (!deal.dateIso) return deal.date;
|
||||
const d = new Date(deal.dateIso);
|
||||
if (Number.isNaN(d.getTime())) return deal.date;
|
||||
return formatJalaliNumeric(d);
|
||||
}
|
||||
|
||||
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
|
||||
|
||||
@@ -213,10 +222,12 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'date'">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<span>{{ record.deal.date }}</span>
|
||||
</a-space>
|
||||
<a-tooltip :title="dateHint(record.deal)">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<span>{{ record.deal.date }}</span>
|
||||
</a-space>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<span :data-card-id="record.deal.id">
|
||||
@@ -334,10 +345,12 @@ onUnmounted(() => {
|
||||
{{ row.stageTitle }}
|
||||
</a-tag>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-tooltip :title="dateHint(row.deal)">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
|
||||
</a-space>
|
||||
</a-tooltip>
|
||||
<span v-if="!hideAmount" dir="ltr">
|
||||
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
localInputToIso,
|
||||
@@ -68,10 +68,10 @@ const form = reactive({
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorityOptions = computed(() =>
|
||||
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
),
|
||||
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
|
||||
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatJalaliDayClock } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
ROUTINE_ENTITY_LINK_LABELS,
|
||||
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
|
||||
const auth = useAuthStore();
|
||||
const canManage = computed(() => !!shell?.context.value?.isManager);
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorities = computed(() =>
|
||||
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
|
||||
);
|
||||
|
||||
function startEdit() {
|
||||
const t = task.value;
|
||||
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
|
||||
<div class="badges">
|
||||
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
|
||||
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
|
||||
اولویت {{ PRIORITY_LABELS[task.priority] }}
|
||||
اولویت {{ activityPriorities.labelOf(task.priority) }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
|
||||
const AMenuItem = AMenu.Item;
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
ROUTINE_TASK_SOURCE_LABELS,
|
||||
ROUTINE_TASK_STATUS_LABELS,
|
||||
entityRoute,
|
||||
@@ -64,6 +64,9 @@ const emit = defineEmits<{
|
||||
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
|
||||
}>();
|
||||
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
|
||||
const done = computed(() => props.task.status === 'completed');
|
||||
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
|
||||
const open = computed(() => isOpenStatus(props.task.status));
|
||||
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<CheckOutlined /> {{ formatTime(task.completedAt) }}
|
||||
</a-typography-text>
|
||||
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
|
||||
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
|
||||
<a-tag :bordered="false">{{ activityPriorities.labelOf(task.priority) }}</a-tag>
|
||||
<a-tag v-if="task.status !== 'todo'" :bordered="false">
|
||||
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
|
||||
</a-tag>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
|
||||
const links = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
@@ -41,6 +42,17 @@ const active = computed(() => {
|
||||
return '/finance';
|
||||
});
|
||||
|
||||
watch(
|
||||
active,
|
||||
async () => {
|
||||
await nextTick();
|
||||
scroller.value
|
||||
?.querySelector('.ant-segmented-item-selected')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onChange(value: string | number) {
|
||||
const next = String(value);
|
||||
if (next && next !== route.path) void router.push(next);
|
||||
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scroller"
|
||||
:style="{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Table as ATable } from 'ant-design-vue';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: TreasuryPartyBalanceDto[];
|
||||
settledLabel: string;
|
||||
}>();
|
||||
|
||||
const isMobile = useTreasuryMobile();
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
@@ -39,10 +49,43 @@ const columns = computed(() => [
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||
},
|
||||
]);
|
||||
|
||||
function kindOf(record: TreasuryPartyBalanceDto) {
|
||||
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list
|
||||
v-if="isMobile"
|
||||
:data-source="rows"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-flex vertical :gap="4" style="width: 100%">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-text strong>{{ item.partyName }}</a-typography-text>
|
||||
<a-typography-text type="secondary">{{ kindOf(item) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">مبلغ کل</a-typography-text>
|
||||
<a-typography-text>{{ toman(item.amount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">{{ settledLabel }}</a-typography-text>
|
||||
<a-typography-text>{{ toman(item.settledAmount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">مانده</a-typography-text>
|
||||
<a-typography-text strong>{{ toman(item.remaining) }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
|
||||
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
|
||||
const ASelectOptGroup = ASelect.OptGroup;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
withDefaults(
|
||||
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
|
||||
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
|
||||
);
|
||||
|
||||
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||
const partyId = defineModel<string | undefined>('partyId');
|
||||
|
||||
@@ -156,12 +161,12 @@ onMounted(() => {
|
||||
:filter-option="false"
|
||||
:loading="loading"
|
||||
:value="selectValue"
|
||||
placeholder="مشتری، شرکت، کاربر یا سایر"
|
||||
:placeholder="placeholder"
|
||||
style="width: 100%"
|
||||
@search="onSearch"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-select-option value="other" label="سایر">سایر</a-select-option>
|
||||
<a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
|
||||
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||
<a-select-option
|
||||
v-for="row in peopleOptions"
|
||||
|
||||
@@ -184,8 +184,43 @@ function onSizeChange(e: Event) {
|
||||
@media (max-width: 900px) {
|
||||
.ui-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
align-items: stretch;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.page-count {
|
||||
flex: 0 1 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.size-picker {
|
||||
margin-inline-start: 0;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.size-picker span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.size-picker select {
|
||||
height: 28px;
|
||||
min-width: 3.25rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import {
|
||||
canOpenPath,
|
||||
firstAllowedFinancePath,
|
||||
hasSectionPermission,
|
||||
sectionPermissionForPath,
|
||||
} from '@/lib/section-permissions';
|
||||
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
|
||||
}) {
|
||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||
const perms = auth.user?.permissions ?? [];
|
||||
const canTreasury = auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| perms.includes('treasury')
|
||||
|| perms.some((key) => key.startsWith('treasury_'));
|
||||
if (card.to?.startsWith('/finance')) {
|
||||
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
|
||||
return (
|
||||
moduleAllowed(card.module ?? 'treasury')
|
||||
&& Boolean(firstAllowedFinancePath(auth.user?.role, perms))
|
||||
);
|
||||
}
|
||||
const section = card.to ? sectionPermissionForPath(card.to) : null;
|
||||
const hasSection = section
|
||||
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
|
||||
if (!openHubKey.value) return null;
|
||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||
if (!hub) return null;
|
||||
const cards = hub.cards.filter((card) => cardAllowedForNav(card));
|
||||
const financeTo = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
|
||||
const cards = hub.cards
|
||||
.filter((card) => cardAllowedForNav(card))
|
||||
.map((card) => {
|
||||
if (!financeTo || !card.to.startsWith('/finance') || card.to === financeTo) return card;
|
||||
return { ...card, to: financeTo };
|
||||
});
|
||||
if (
|
||||
hub.key === 'customers' &&
|
||||
isMobileNav.value &&
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ActivityDto, ActivityEditPolicy } from '@mento/shared';
|
||||
|
||||
type ActivityAccessUser = {
|
||||
id?: string | null;
|
||||
role?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
type ActivityAccessRow = Pick<
|
||||
ActivityDto,
|
||||
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
|
||||
> | null | undefined;
|
||||
|
||||
function isAssignee(
|
||||
activity: NonNullable<ActivityAccessRow>,
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (activity.assignee?.id === userId) return true;
|
||||
return (activity.assignees ?? []).some((a) => a.id === userId);
|
||||
}
|
||||
|
||||
/** ویرایش/حذف فیلدهای فعالیت — انجام/جابهجایی ستون جداست */
|
||||
export function canMutateActivity(
|
||||
activity: ActivityAccessRow,
|
||||
user: ActivityAccessUser,
|
||||
): boolean {
|
||||
if (!activity || !user?.id) return false;
|
||||
if (user.role === 'owner' || user.role === 'admin') return true;
|
||||
if (activity.createdBy?.id === user.id) return true;
|
||||
const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator';
|
||||
if (policy !== 'assignees') return false;
|
||||
return isAssignee(activity, user.id);
|
||||
}
|
||||
|
||||
/** ثبت نظر و تعویق برای همهٔ مسئولان (حتی وقتی ویرایش فقط با ایجادکننده است) */
|
||||
export function canCollaborateOnActivity(
|
||||
activity: ActivityAccessRow,
|
||||
user: ActivityAccessUser,
|
||||
): boolean {
|
||||
if (!activity || !user?.id) return false;
|
||||
if (user.role === 'owner' || user.role === 'admin') return true;
|
||||
if (activity.createdBy?.id === user.id) return true;
|
||||
return isAssignee(activity, user.id);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ActivityPriority, ActivityPrioritySetting } from '@mento/shared';
|
||||
import {
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
parseActivityPrioritySettings,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const items = ref<ActivityPrioritySetting[]>([...DEFAULT_ACTIVITY_PRIORITY_SETTINGS]);
|
||||
const loaded = ref(false);
|
||||
let pending: Promise<void> | null = null;
|
||||
|
||||
/** تنظیمات اولویت فعالیت/روتین (کش مشترک) */
|
||||
export function useActivityPriorities() {
|
||||
async function load(force = false): Promise<void> {
|
||||
if (loaded.value && !force) return;
|
||||
if (pending && !force) return pending;
|
||||
pending = api
|
||||
.get<ActivityPrioritySetting[]>('/activities/priorities')
|
||||
.then(({ data }) => {
|
||||
items.value = parseActivityPrioritySettings(data);
|
||||
loaded.value = true;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!loaded.value) items.value = [...DEFAULT_ACTIVITY_PRIORITY_SETTINGS];
|
||||
})
|
||||
.finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
const byKey = computed(() => {
|
||||
const map = new Map<ActivityPriority, ActivityPrioritySetting>();
|
||||
for (const row of items.value) map.set(row.key, row);
|
||||
return map;
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
const out = {} as Record<ActivityPriority, string>;
|
||||
for (const row of items.value) out[row.key] = row.label;
|
||||
return out;
|
||||
});
|
||||
|
||||
const colors = computed(() => {
|
||||
const out = {} as Record<ActivityPriority, string>;
|
||||
for (const row of items.value) out[row.key] = row.color;
|
||||
return out;
|
||||
});
|
||||
|
||||
function labelOf(key: ActivityPriority | string | null | undefined): string {
|
||||
if (!key) return '—';
|
||||
return byKey.value.get(key as ActivityPriority)?.label ?? String(key);
|
||||
}
|
||||
|
||||
function colorOf(key: ActivityPriority | string | null | undefined): string {
|
||||
if (!key) return '#94a3b8';
|
||||
return byKey.value.get(key as ActivityPriority)?.color ?? '#94a3b8';
|
||||
}
|
||||
|
||||
function setLocal(next: ActivityPrioritySetting[]) {
|
||||
items.value = parseActivityPrioritySettings(next);
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
return { items, byKey, labels, colors, loaded, load, labelOf, colorOf, setLocal };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { jalaliToDate } from '@/lib/jalali';
|
||||
|
||||
export type ActivityThreadEntry = {
|
||||
at: Date | null;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
author: string;
|
||||
body: string;
|
||||
/** شناسهٔ فایلهای چسبیده به این یادداشت (عکس/صوت/فایل) */
|
||||
attachmentIds?: string[];
|
||||
};
|
||||
|
||||
const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/;
|
||||
const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i;
|
||||
const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000;
|
||||
|
||||
/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل میکند. */
|
||||
export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null {
|
||||
const text = (raw ?? '').replace(/\r\n/g, '\n').trim();
|
||||
if (!text) return null;
|
||||
|
||||
const entries: ActivityThreadEntry[] = [];
|
||||
const preamble: string[] = [];
|
||||
let current: ActivityThreadEntry | null = null;
|
||||
let sawStamp = false;
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
const stamp = trimmed.match(STAMP);
|
||||
if (stamp) {
|
||||
sawStamp = true;
|
||||
if (current) entries.push(trimEntry(current));
|
||||
current = {
|
||||
at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''),
|
||||
dateLabel: (stamp[1] ?? '').trim(),
|
||||
timeLabel: (stamp[2] ?? '').trim(),
|
||||
author: (stamp[3] ?? '').trim(),
|
||||
body: '',
|
||||
attachmentIds: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const attach = trimmed.match(ATTACHMENTS);
|
||||
if (attach && current) {
|
||||
current.attachmentIds = attach[1]!
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
if (current) current.body += `${current.body ? '\n' : ''}${line}`;
|
||||
else preamble.push(line);
|
||||
}
|
||||
if (current) entries.push(trimEntry(current));
|
||||
if (!sawStamp) return null;
|
||||
|
||||
const head = preamble.join('\n').trim();
|
||||
if (head) {
|
||||
entries.unshift({
|
||||
at: null,
|
||||
dateLabel: '',
|
||||
timeLabel: '',
|
||||
author: '',
|
||||
body: head,
|
||||
attachmentIds: [],
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** برگرداندن متن ذخیرهشونده از نظرهای جدا. */
|
||||
export function serializeActivityThread(entries: ActivityThreadEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const ids = (entry.attachmentIds ?? []).filter(Boolean);
|
||||
const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : '';
|
||||
if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim();
|
||||
const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`;
|
||||
return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`;
|
||||
})
|
||||
.filter((chunk) => chunk.trim())
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** فایلها را به یادداشتها میچسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */
|
||||
export function bindThreadMedia<T extends { id: string; createdAt: string }>(
|
||||
entries: ActivityThreadEntry[],
|
||||
images: T[],
|
||||
windowMs = COMMENT_MEDIA_WINDOW_MS,
|
||||
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; loose: T[] } {
|
||||
const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] }));
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = new Set<string>();
|
||||
|
||||
blocks.forEach((block) => {
|
||||
for (const id of block.attachmentIds ?? []) {
|
||||
const image = byId.get(id);
|
||||
if (!image || used.has(id)) continue;
|
||||
block.media.push(image);
|
||||
used.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
for (const image of images) {
|
||||
if (used.has(image.id)) continue;
|
||||
const at = new Date(image.createdAt).getTime();
|
||||
if (Number.isNaN(at)) continue;
|
||||
let best = -1;
|
||||
let bestDelta = Number.POSITIVE_INFINITY;
|
||||
blocks.forEach((block, index) => {
|
||||
if (!block.at) return;
|
||||
const delta = block.at.getTime() - at;
|
||||
if (delta >= -60_000 && delta < bestDelta) {
|
||||
best = index;
|
||||
bestDelta = delta;
|
||||
}
|
||||
});
|
||||
if (best >= 0 && bestDelta <= windowMs) {
|
||||
blocks[best]!.media.push(image);
|
||||
used.add(image.id);
|
||||
}
|
||||
}
|
||||
|
||||
const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY;
|
||||
const preamble = blocks.find((block) => !block.at);
|
||||
const loose: T[] = [];
|
||||
for (const image of images) {
|
||||
if (used.has(image.id)) continue;
|
||||
const at = new Date(image.createdAt).getTime();
|
||||
if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image);
|
||||
else loose.push(image);
|
||||
}
|
||||
return { blocks, loose };
|
||||
}
|
||||
|
||||
function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry {
|
||||
return {
|
||||
...entry,
|
||||
body: entry.body.trim(),
|
||||
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
|
||||
};
|
||||
}
|
||||
|
||||
function latinDigits(value: string): string {
|
||||
const fa = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ar = '٠١٢٣٤٥٦٧٨٩';
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const faIndex = fa.indexOf(ch);
|
||||
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
|
||||
});
|
||||
}
|
||||
|
||||
function stampToDate(dateLabel: string, timeLabel: string): Date | null {
|
||||
const date = latinDigits(dateLabel).match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
|
||||
if (!date) return null;
|
||||
const at = jalaliToDate(Number(date[1]), Number(date[2]), Number(date[3]));
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
const time = latinDigits(timeLabel).match(/(\d{1,2}):(\d{2})/);
|
||||
if (time) at.setHours(Number(time[1]), Number(time[2]), 0, 0);
|
||||
return at;
|
||||
}
|
||||
@@ -226,6 +226,17 @@ export function formatRelativeContact(iso: string | null | undefined): string {
|
||||
return `${toFaDigits(days)} روز پیش`;
|
||||
}
|
||||
|
||||
/** تاریخ نسبی تقویمی برای کارتها: امروز / ۳ روز عقب افتاده / ۵ روز مانده */
|
||||
export function formatRelativeDayLabel(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const daysAhead = calendarDaysBetween(new Date(), d);
|
||||
if (daysAhead === 0) return 'امروز';
|
||||
if (daysAhead > 0) return `${toFaDigits(daysAhead)} روز مانده`;
|
||||
return `${toFaDigits(-daysAhead)} روز عقب افتاده`;
|
||||
}
|
||||
|
||||
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
|
||||
export function formatNextSlot(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import type { ActivityPriority } from '@mento/shared';
|
||||
import type {
|
||||
ActivityPriority,
|
||||
ActivityStatus,
|
||||
DealActivityCue,
|
||||
DealActivitySummaryDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
|
||||
|
||||
@@ -10,6 +15,8 @@ export type PipelineDeal = {
|
||||
description: string;
|
||||
amount: number | null;
|
||||
date: string;
|
||||
/** ISO برای tooltip تاریخ مطلق */
|
||||
dateIso?: string;
|
||||
initials: string;
|
||||
/** شناسه مرحله فعلی — هنگام جابهجایی بهروز میشود */
|
||||
stageId?: string;
|
||||
@@ -20,10 +27,18 @@ export type PipelineDeal = {
|
||||
assigneeNames?: string[];
|
||||
ownerId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
||||
searchFields?: Array<string | null | undefined>;
|
||||
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده میشود */
|
||||
priority?: ActivityPriority;
|
||||
/** وضعیت فعالیت (برای کارت کانبان فعالیت: انجامشده و …) */
|
||||
activityStatus?: ActivityStatus;
|
||||
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
|
||||
activityCue?: DealActivityCue;
|
||||
/** خلاصه فعالیتها برای پاپآپ دکمهٔ تقویم */
|
||||
activitySummaries?: DealActivitySummaryDto[];
|
||||
};
|
||||
|
||||
export type PipelineUiCopy = {
|
||||
@@ -34,6 +49,10 @@ export type PipelineUiCopy = {
|
||||
itemMenuAria: string;
|
||||
listColTitle: string;
|
||||
hideAmount?: boolean;
|
||||
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه بهجای ورود مستقیم به جزئیات */
|
||||
quickView?: boolean;
|
||||
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
|
||||
quickActivity?: boolean;
|
||||
};
|
||||
|
||||
export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
||||
@@ -43,6 +62,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
||||
addItemAria: 'افزودن معامله به این مرحله',
|
||||
itemMenuAria: 'گزینههای معامله',
|
||||
listColTitle: 'معامله',
|
||||
quickActivity: true,
|
||||
};
|
||||
|
||||
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
|
||||
@@ -63,6 +83,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
|
||||
itemMenuAria: 'گزینههای فعالیت',
|
||||
listColTitle: 'فعالیت',
|
||||
hideAmount: true,
|
||||
quickView: true,
|
||||
};
|
||||
|
||||
export type PipelineStage = {
|
||||
@@ -182,8 +203,10 @@ export function mapBoardToStages(
|
||||
expectedCloseAt: string | null;
|
||||
createdAt: string;
|
||||
promotedFromPriorAt: string | null;
|
||||
person: { fullName: string } | null;
|
||||
company: { name: string } | null;
|
||||
activityCue?: DealActivityCue;
|
||||
activitySummaries?: DealActivitySummaryDto[];
|
||||
person: { id?: string; fullName: string } | null;
|
||||
company: { id?: string; name: string } | null;
|
||||
owner?: { id: string; fullName: string } | null;
|
||||
assignees?: Array<{ id: string; fullName: string }>;
|
||||
}>;
|
||||
@@ -226,6 +249,9 @@ export function mapBoardToStages(
|
||||
description,
|
||||
amount: deal.amount ?? 0,
|
||||
date: formatDate(dateIso),
|
||||
dateIso,
|
||||
activityCue: deal.activityCue ?? 'none',
|
||||
activitySummaries: deal.activitySummaries ?? [],
|
||||
initials: initialsFromTitle(
|
||||
deal.company?.name || deal.title,
|
||||
),
|
||||
@@ -236,6 +262,8 @@ export function mapBoardToStages(
|
||||
assigneeNames,
|
||||
ownerId: deal.owner?.id ?? null,
|
||||
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
|
||||
personId: deal.person?.id ?? null,
|
||||
companyId: deal.company?.id ?? null,
|
||||
searchFields: [
|
||||
deal.notes,
|
||||
deal.company?.name,
|
||||
@@ -291,6 +319,7 @@ export function mapProjectBoardToStages(
|
||||
'',
|
||||
amount: null,
|
||||
date: formatDate(card.createdAt),
|
||||
dateIso: card.createdAt,
|
||||
initials: initialsFromTitle(
|
||||
card.company?.name || card.person?.fullName || card.title,
|
||||
),
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
|
||||
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
/** پیشفرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
|
||||
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
|
||||
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
|
||||
return treasurySectionAllowed(role, permissions, section);
|
||||
}
|
||||
|
||||
const FINANCE_ENTRIES: Array<{ path: string; section: TreasurySectionKey }> = [
|
||||
{ path: '/finance', section: 'treasury_dashboard' },
|
||||
{ path: '/finance/incomes', section: 'treasury_incomes' },
|
||||
{ path: '/finance/expenses', section: 'treasury_expenses' },
|
||||
{ path: '/finance/receivables', section: 'treasury_receivables' },
|
||||
{ path: '/finance/accounts', section: 'treasury_accounts' },
|
||||
{ path: '/finance/payroll', section: 'treasury_payroll' },
|
||||
{ path: '/finance/recurring', section: 'treasury_recurring' },
|
||||
{ path: '/finance/reports', section: 'treasury_reports' },
|
||||
{ path: '/finance/audit', section: 'treasury_audit' },
|
||||
{ path: '/finance/categories', section: 'treasury_categories' },
|
||||
];
|
||||
|
||||
/** اولین صفحهٔ مالی که این نقش واقعاً میتواند باز کند. */
|
||||
export function firstAllowedFinancePath(
|
||||
role: UserRole | string | null | undefined,
|
||||
permissions: readonly string[] | null | undefined,
|
||||
): string | null {
|
||||
for (const entry of FINANCE_ENTRIES) {
|
||||
if (treasurySectionAllowed(role, permissions, entry.section)) return entry.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canOpenPath(
|
||||
role: UserRole | string | null | undefined,
|
||||
permissions: readonly string[] | null | undefined,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
|
||||
import {
|
||||
canOpenFinancePath,
|
||||
canOpenPath,
|
||||
firstAllowedFinancePath,
|
||||
sectionPermissionForPath,
|
||||
} from '@/lib/section-permissions';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -683,13 +688,8 @@ router.beforeEach((to) => {
|
||||
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||
return true;
|
||||
}
|
||||
const exactHome = to.path === '/finance' || to.path === '/finance/';
|
||||
if (
|
||||
exactHome &&
|
||||
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
|
||||
) {
|
||||
return { path: '/finance/payroll' };
|
||||
}
|
||||
const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
|
||||
if (next && next !== to.path) return { path: next };
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
|
||||
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
if (token.value) void hydrate();
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') void hydrate();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
|
||||
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
@@ -42,6 +43,7 @@ import type {
|
||||
ActivityBoardDto,
|
||||
ActivityDto,
|
||||
ActivityFilter,
|
||||
ActivityPriority,
|
||||
ActivityStageCountsDto,
|
||||
ActivityStageDto,
|
||||
ActivityStatus,
|
||||
@@ -50,9 +52,12 @@ import type {
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { ACTIVITY_PRIORITY_KEYS, brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canMutateActivity } from '@/lib/activity-access';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { apiError } from '@/lib/routines';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
@@ -91,6 +96,7 @@ const actGridStyle = computed(() => ({
|
||||
|
||||
/** 'all' یا شناسهٔ نوع فعالیت */
|
||||
type TypeFilter = string;
|
||||
type PriorityFilter = 'all' | ActivityPriority;
|
||||
type StatusFilter = 'all' | ActivityStatus;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
@@ -118,6 +124,18 @@ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'canceled', label: 'لغو شده' },
|
||||
];
|
||||
|
||||
const activityPriorities = useActivityPriorities();
|
||||
const PRIORITY_FILTERS = computed<
|
||||
Array<{ key: PriorityFilter; label: string; color?: string }>
|
||||
>(() => [
|
||||
{ key: 'all', label: 'همه اولویتها' },
|
||||
...activityPriorities.items.value.map((row) => ({
|
||||
key: row.key as PriorityFilter,
|
||||
label: row.label,
|
||||
color: row.color,
|
||||
})),
|
||||
]);
|
||||
|
||||
const pageSize = usePageSize('activities', 10);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -177,6 +195,7 @@ const boardDeleteOpen = ref(false);
|
||||
|
||||
async function onTypesChanged() {
|
||||
await activityTypes.load(true);
|
||||
await activityPriorities.load(true);
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
}
|
||||
@@ -201,6 +220,7 @@ const page = computed(() => {
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
@@ -239,6 +259,13 @@ const statusFilter = computed<StatusFilter>(() => {
|
||||
: 'all';
|
||||
});
|
||||
|
||||
const priorityFilter = computed<PriorityFilter>(() => {
|
||||
const value = typeof route.query.priority === 'string' ? route.query.priority : 'all';
|
||||
return ACTIVITY_PRIORITY_KEYS.includes(value as ActivityPriority)
|
||||
? (value as ActivityPriority)
|
||||
: 'all';
|
||||
});
|
||||
|
||||
const assigneeFilter = computed(() =>
|
||||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||||
);
|
||||
@@ -317,6 +344,7 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
|
||||
? legacyBaseType.value
|
||||
: undefined,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
priority: priorityFilter.value === 'all' ? undefined : priorityFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
title: titleFilter.value.trim() || undefined,
|
||||
customer: customerFilter.value.trim() || undefined,
|
||||
@@ -329,6 +357,7 @@ function stageDotStyle(s: ActivityStageDto) {
|
||||
const activeFacetCount = computed(() => {
|
||||
let n = 0;
|
||||
if (typeFilter.value !== 'all') n += 1;
|
||||
if (priorityFilter.value !== 'all') n += 1;
|
||||
if (statusFilter.value !== 'all') n += 1;
|
||||
if (assigneeFilter.value) n += 1;
|
||||
if (titleFilter.value.trim()) n += 1;
|
||||
@@ -431,6 +460,7 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
|
||||
if (search) query.q = search;
|
||||
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
|
||||
if (statusFilter.value !== 'all') query.status = statusFilter.value;
|
||||
if (priorityFilter.value !== 'all') query.priority = priorityFilter.value;
|
||||
if (assigneeFilter.value) query.assigneeId = assigneeFilter.value;
|
||||
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
|
||||
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
|
||||
@@ -445,23 +475,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
|
||||
return query;
|
||||
}
|
||||
|
||||
function activityRoute(id: string, edit = false, columnStageId?: string) {
|
||||
if (!edit) {
|
||||
return {
|
||||
name: 'activity-detail' as const,
|
||||
params: { id },
|
||||
query: deskDetailQuery(columnStageId),
|
||||
};
|
||||
}
|
||||
const query: Record<string, string> = {};
|
||||
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) {
|
||||
const value = route.query[key];
|
||||
if (typeof value === 'string' && value) query[key] = value;
|
||||
}
|
||||
function activityRoute(id: string, columnStageId?: string) {
|
||||
return {
|
||||
name: 'activity-edit' as const,
|
||||
name: 'activity-detail' as const,
|
||||
params: { id },
|
||||
query,
|
||||
query: deskDetailQuery(columnStageId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -781,6 +799,13 @@ function setStatusFilter(next: string) {
|
||||
patchFacetQuery({ status: value === 'all' ? undefined : value });
|
||||
}
|
||||
|
||||
function setPriorityFilter(next: string) {
|
||||
const value: PriorityFilter = ACTIVITY_PRIORITY_KEYS.includes(next as ActivityPriority)
|
||||
? (next as ActivityPriority)
|
||||
: 'all';
|
||||
patchFacetQuery({ priority: value === 'all' ? undefined : value });
|
||||
}
|
||||
|
||||
function setAssigneeFilter(next: string) {
|
||||
patchFacetQuery({
|
||||
assigneeId: next || undefined,
|
||||
@@ -805,6 +830,7 @@ function clearFacets() {
|
||||
type: undefined,
|
||||
typeDefId: undefined,
|
||||
status: undefined,
|
||||
priority: undefined,
|
||||
assigneeId: undefined,
|
||||
title: undefined,
|
||||
customer: undefined,
|
||||
@@ -837,6 +863,30 @@ function onFacetDone() {
|
||||
openCol.value = null;
|
||||
}
|
||||
|
||||
function assigneePeopleOf(item: ActivityDto) {
|
||||
if (item.assignees?.length) return item.assignees;
|
||||
return item.assignee ? [item.assignee] : [];
|
||||
}
|
||||
|
||||
function needsEveryone(item: ActivityDto) {
|
||||
return item.completionMode === 'all' && assigneePeopleOf(item).length > 1;
|
||||
}
|
||||
|
||||
function canMarkShare(item: ActivityDto) {
|
||||
if (item.status !== 'open') return false;
|
||||
if (!needsEveryone(item)) return true;
|
||||
const mine = auth.user?.id;
|
||||
if (!mine) return false;
|
||||
return (
|
||||
assigneePeopleOf(item).some((person) => person.id === mine) &&
|
||||
!item.completedByIds?.includes(mine)
|
||||
);
|
||||
}
|
||||
|
||||
function completeLabel(item: ActivityDto) {
|
||||
return needsEveryone(item) ? 'انجام سهم من' : 'انجام شد';
|
||||
}
|
||||
|
||||
async function complete(id: string) {
|
||||
menuId.value = null;
|
||||
boardMenuId.value = null;
|
||||
@@ -913,10 +963,27 @@ function clearSelect() {
|
||||
selectedIds.value = new Set();
|
||||
}
|
||||
|
||||
function canDeleteActivity(item: ActivityDto | null | undefined) {
|
||||
return canMutateActivity(item, auth.user);
|
||||
}
|
||||
|
||||
function activityById(id: string) {
|
||||
return (
|
||||
items.value.find((row) => row.id === id) ||
|
||||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
|
||||
);
|
||||
}
|
||||
|
||||
function askDeleteActivity(id: string, title?: string) {
|
||||
menuId.value = null;
|
||||
boardMenuId.value = null;
|
||||
const target = activityById(id);
|
||||
boardMenuActivity.value = null;
|
||||
if (target && !canDeleteActivity(target)) {
|
||||
message.warning('شما مجوز حذف این فعالیت را ندارید.');
|
||||
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
deleteSingleTitle.value = title?.trim() || activityTitle(id);
|
||||
deleteActivityIds.value = [id];
|
||||
}
|
||||
@@ -926,8 +993,24 @@ function askBulkDelete() {
|
||||
menuId.value = null;
|
||||
boardMenuId.value = null;
|
||||
boardMenuActivity.value = null;
|
||||
const selected = [...selectedIds.value];
|
||||
const allowed = selected.filter((id) => {
|
||||
const row = activityById(id);
|
||||
return !row || canDeleteActivity(row);
|
||||
});
|
||||
const blocked = selected.length - allowed.length;
|
||||
if (!allowed.length) {
|
||||
message.warning('شما مجوز حذف این فعالیتها را ندارید.');
|
||||
error.value = 'شما مجوز حذف این فعالیتها را ندارید.';
|
||||
return;
|
||||
}
|
||||
if (blocked > 0) {
|
||||
message.warning(
|
||||
`${toFaDigits(blocked)} مورد بهخاطر نبود مجوز حذف کنار گذاشته شد.`,
|
||||
);
|
||||
}
|
||||
deleteSingleTitle.value = '';
|
||||
deleteActivityIds.value = [...selectedIds.value];
|
||||
deleteActivityIds.value = allowed;
|
||||
}
|
||||
|
||||
async function confirmDeleteActivities() {
|
||||
@@ -937,16 +1020,24 @@ async function confirmDeleteActivities() {
|
||||
error.value = '';
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
deleteActivityIds.value = [];
|
||||
clearSelect();
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
if (failed) {
|
||||
if (failed.length) {
|
||||
const first = failed[0];
|
||||
const detail =
|
||||
first && first.status === 'rejected'
|
||||
? apiError(first.reason, '')
|
||||
: '';
|
||||
error.value =
|
||||
failed === ids.length
|
||||
? 'حذف فعالیتها ناموفق بود.'
|
||||
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
||||
failed.length === ids.length
|
||||
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
|
||||
: detail
|
||||
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
|
||||
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
|
||||
message.error(error.value);
|
||||
}
|
||||
} finally {
|
||||
activityDeleting.value = false;
|
||||
@@ -1105,6 +1196,7 @@ onMounted(() => {
|
||||
mobileQuery.addEventListener('change', syncMobile);
|
||||
void loadUsers();
|
||||
void activityTypes.load(true);
|
||||
void activityPriorities.load();
|
||||
void loadBoards()
|
||||
.then(() => loadStages())
|
||||
.then(() => {
|
||||
@@ -1160,11 +1252,11 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
title="انواع فعالیت و فیلدها"
|
||||
title="انواع فعالیت و اولویت ها"
|
||||
@click="typesManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="sliders" :size="14" />
|
||||
<span class="manage-label">انواع فعالیت و فیلدها</span>
|
||||
<span class="manage-label">انواع فعالیت و اولویت ها</span>
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="canManageStages"
|
||||
@@ -1323,7 +1415,7 @@ onUnmounted(() => {
|
||||
<hr class="board-menu-sep" />
|
||||
<button type="button" class="menu-row" @click="typesManagerOpen = true">
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
انواع فعالیت و فیلدها
|
||||
انواع فعالیت و اولویت ها
|
||||
</button>
|
||||
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
|
||||
<UiIcon name="settings" :size="16" />
|
||||
@@ -1402,16 +1494,19 @@ onUnmounted(() => {
|
||||
:customer="customerFilter"
|
||||
:due="dueFilter"
|
||||
:type-key="typeFilter"
|
||||
:priority-key="priorityFilter"
|
||||
:status-key="statusFilter"
|
||||
:assignee-id="assigneeFilter"
|
||||
:show-assignee="showDeskTabs"
|
||||
:users="users"
|
||||
:types="TYPE_FILTERS"
|
||||
:priorities="PRIORITY_FILTERS"
|
||||
:statuses="STATUS_FILTERS"
|
||||
@title="setTitleFilter"
|
||||
@customer="setCustomerFilter"
|
||||
@due="setDueFilter"
|
||||
@type="setTypeFilter"
|
||||
@priority="setPriorityFilter"
|
||||
@status="setStatusFilter"
|
||||
@assignee="setAssigneeFilter"
|
||||
@clear="clearFacets"
|
||||
@@ -1503,6 +1598,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="notice" class="ok">{{ notice }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
|
||||
<div v-if="selectMode" class="select-bar">
|
||||
@@ -1532,6 +1628,7 @@ onUnmounted(() => {
|
||||
@stages-changed="onStagesChanged"
|
||||
@add="openAddInStage"
|
||||
@error="(msg: string) => (error = msg)"
|
||||
@notice="(msg: string) => (notice = msg)"
|
||||
@menu-deal="onBoardMenu"
|
||||
@toggle-select="toggleSelect"
|
||||
@bulk-delete="askBulkDelete"
|
||||
@@ -1619,16 +1716,19 @@ onUnmounted(() => {
|
||||
:customer="customerFilter"
|
||||
:due="dueFilter"
|
||||
:type-key="typeFilter"
|
||||
:priority-key="priorityFilter"
|
||||
:status-key="statusFilter"
|
||||
:assignee-id="assigneeFilter"
|
||||
:show-assignee="showDeskTabs"
|
||||
:users="users"
|
||||
:types="TYPE_FILTERS"
|
||||
:priorities="PRIORITY_FILTERS"
|
||||
:statuses="STATUS_FILTERS"
|
||||
@title="setTitleFilter"
|
||||
@customer="setCustomerFilter"
|
||||
@due="setDueFilter"
|
||||
@type="setTypeFilter"
|
||||
@priority="setPriorityFilter"
|
||||
@status="setStatusFilter"
|
||||
@assignee="setAssigneeFilter"
|
||||
@done="onFacetDone"
|
||||
@@ -1749,16 +1849,18 @@ onUnmounted(() => {
|
||||
<RouterLink :to="activityRoute(item.id)">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink :to="activityRoute(item.id, true)">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
|
||||
{{ completeLabel(item) }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
|
||||
<button
|
||||
v-if="canDeleteActivity(item)"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="askDeleteActivity(item.id, item.title)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
@@ -1839,16 +1941,18 @@ onUnmounted(() => {
|
||||
<RouterLink :to="activityRoute(item.id)">
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink :to="activityRoute(item.id, true)">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
|
||||
انجام شد
|
||||
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
|
||||
{{ completeLabel(item) }}
|
||||
</button>
|
||||
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
|
||||
<button
|
||||
v-if="canDeleteActivity(item)"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="askDeleteActivity(item.id, item.title)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
@@ -1895,18 +1999,11 @@ onUnmounted(() => {
|
||||
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
|
||||
<RouterLink
|
||||
role="menuitem"
|
||||
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
|
||||
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
|
||||
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
|
||||
>
|
||||
مشاهده
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
role="menuitem"
|
||||
:to="activityRoute(boardMenuId, true)"
|
||||
@click="boardMenuId = null; boardMenuActivity = null"
|
||||
>
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
|
||||
type="button"
|
||||
@@ -1916,6 +2013,7 @@ onUnmounted(() => {
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteActivity(boardMenuActivity)"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import {
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
@@ -89,6 +90,8 @@ const boardId = ref('');
|
||||
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const title = ref('');
|
||||
const type = ref<ActivityType | ''>('');
|
||||
const typeDefId = ref('');
|
||||
@@ -129,6 +132,8 @@ const personId = ref('');
|
||||
const leadId = ref('');
|
||||
const dealId = ref('');
|
||||
const assigneeIds = ref<string[]>([]);
|
||||
const completionMode = ref<'any' | 'all'>('any');
|
||||
const editPolicy = ref<'creator' | 'assignees'>('creator');
|
||||
const isDone = ref(false);
|
||||
const needsNote = ref(false);
|
||||
const reminderAt = ref('');
|
||||
@@ -269,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
|
||||
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
|
||||
{ value: 'high', label: 'زیاد', tone: 'high' },
|
||||
{ value: 'medium', label: 'متوسط', tone: 'medium' },
|
||||
{ value: 'low', label: 'کم', tone: 'low' },
|
||||
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
|
||||
];
|
||||
const priorityOptions = computed(() =>
|
||||
activityPriorities.items.value.map((row) => ({
|
||||
value: row.key,
|
||||
label: row.label,
|
||||
color: row.color,
|
||||
})),
|
||||
);
|
||||
|
||||
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
|
||||
const typeFields = computed(() => typeMeta.value?.fields ?? []);
|
||||
@@ -400,7 +405,7 @@ const duePreview = computed(() => {
|
||||
|
||||
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
|
||||
const priorityText = computed(
|
||||
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—',
|
||||
() => priorityOptions.value.find((p) => p.value === priority.value)?.label || '—',
|
||||
);
|
||||
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
|
||||
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
|
||||
@@ -440,6 +445,8 @@ function resetForm() {
|
||||
personId.value = '';
|
||||
dealId.value = '';
|
||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||
completionMode.value = 'any';
|
||||
editPolicy.value = 'creator';
|
||||
pickedRoleIds.value = [];
|
||||
pickedTeamIds.value = [];
|
||||
isDone.value = false;
|
||||
@@ -528,6 +535,8 @@ async function load() {
|
||||
: auth.user?.id
|
||||
? [auth.user.id]
|
||||
: [];
|
||||
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
|
||||
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
|
||||
refreshPickedGroups();
|
||||
isDone.value = data.status === 'done';
|
||||
await loadSavedPhotos(data);
|
||||
@@ -541,6 +550,16 @@ async function load() {
|
||||
personId.value = incomingPerson;
|
||||
}
|
||||
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
|
||||
const incomingDeal =
|
||||
typeof route.query.dealId === 'string' ? route.query.dealId : '';
|
||||
if (incomingDeal) {
|
||||
dealId.value = incomingDeal;
|
||||
const linkedDeal = deals.value.find((d) => d.id === incomingDeal);
|
||||
if (linkedDeal?.person?.id && !personId.value) {
|
||||
await ensurePersonLoaded(linkedDeal.person.id);
|
||||
personId.value = linkedDeal.person.id;
|
||||
}
|
||||
}
|
||||
const incomingDef =
|
||||
typeof route.query.typeDefId === 'string'
|
||||
? activityTypes.byId.value.get(route.query.typeDefId)
|
||||
@@ -605,6 +624,8 @@ function payload(): CreateActivityRequest {
|
||||
leadId: leadId.value || null,
|
||||
assigneeIds: [...assigneeIds.value],
|
||||
assigneeId: assigneeIds.value[0] || null,
|
||||
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
|
||||
editPolicy: editPolicy.value,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -978,7 +999,9 @@ async function submit(next: 'detail' | 'new' = 'detail') {
|
||||
savedId = data.id;
|
||||
createdActivityId.value = data.id;
|
||||
if (isDone.value) {
|
||||
await api.patch(`/activities/${savedId}/complete`);
|
||||
await api.patch(`/activities/${savedId}/complete`, undefined, {
|
||||
params: { force: '1' },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await api.patch(`/activities/${savedId}`, {
|
||||
@@ -1269,6 +1292,20 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="assigneeIds.length > 1" class="completion-mode">
|
||||
<span>نحوهٔ انجام</span>
|
||||
<label>
|
||||
<input v-model="completionMode" type="radio" value="any" />
|
||||
با انجام یکی کافی است
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="completionMode" type="radio" value="all" />
|
||||
همهٔ مسئولها باید انجام دهند
|
||||
</label>
|
||||
<p v-if="completionMode === 'all'" class="completion-hint">
|
||||
هر کدام از همین افراد باید جداگانه سهم خودش را انجام دهد.
|
||||
</p>
|
||||
</div>
|
||||
<small class="hint">
|
||||
{{
|
||||
canAssignOthers
|
||||
@@ -1302,6 +1339,29 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label>ویرایش و حذف</label>
|
||||
<div class="completion-mode">
|
||||
<label>
|
||||
<input v-model="editPolicy" type="radio" value="creator" />
|
||||
فقط خودم (ایجادکننده)
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="editPolicy" type="radio" value="assignees" />
|
||||
همهٔ مسئولان
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">
|
||||
{{
|
||||
editPolicy === 'assignees'
|
||||
? 'مسئولان هم میتوانند این فعالیت را ویرایش یا حذف کنند.'
|
||||
: 'فقط ایجادکننده میتواند این فعالیت را ویرایش یا حذف کند.'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<div class="label-row">
|
||||
@@ -1385,7 +1445,13 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<div class="card">
|
||||
<p class="pri-label">اولویت</p>
|
||||
<div class="priorities">
|
||||
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]">
|
||||
<label
|
||||
v-for="opt in priorityOptions"
|
||||
:key="opt.value"
|
||||
class="pri"
|
||||
:class="{ on: priority === opt.value }"
|
||||
:style="{ color: opt.color }"
|
||||
>
|
||||
<input v-model="priority" type="radio" :value="opt.value" />
|
||||
<UiIcon name="flag" :size="16" />
|
||||
<span>{{ opt.label }}</span>
|
||||
@@ -1799,6 +1865,66 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.completion-mode {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.completion-mode > span {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.completion-mode label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.completion-mode label:has(input:checked) {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.completion-mode input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 14px;
|
||||
accent-color: #0d9488;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.completion-hint {
|
||||
width: 100%;
|
||||
margin: 0.15rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.assignee-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2248,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pri.very-high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.pri.high {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.pri.medium {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.pri.low {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.pri.very-low {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pri.on span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
@@ -44,9 +45,11 @@ import { toFaDigits } from '@/lib/jalali';
|
||||
import { currencySuffix, formatMoney } from '@/lib/money';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import RelatedActivitiesPanel from '@/components/activities/RelatedActivitiesPanel.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import StagePathStrip from '@/components/StagePathStrip.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
import type {
|
||||
@@ -104,6 +107,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const stageMoving = ref(false);
|
||||
const error = ref('');
|
||||
const showOthers = ref(true);
|
||||
const othersKeys = ref<string[]>(['others']);
|
||||
@@ -197,6 +201,43 @@ const pipelineSelectOptions = computed(() =>
|
||||
const stageSelectOptions = computed(() =>
|
||||
stages.value.map((s) => ({ value: s.id, label: s.name })),
|
||||
);
|
||||
const stagePathItems = computed(() => stages.value.map((s) => ({ id: s.id, name: s.name })));
|
||||
|
||||
function onStageSelect(nextId: string) {
|
||||
if (!nextId || nextId === stageId.value || stageMoving.value || saving.value) return;
|
||||
const target = stages.value.find((stage) => stage.id === nextId);
|
||||
if (!target) return;
|
||||
const prev = stageId.value;
|
||||
Modal.confirm({
|
||||
title: 'جابهجایی مرحله',
|
||||
content: `به مرحله «${target.name}» منتقل میشود. انجام شود؟`,
|
||||
okText: 'انجام بده',
|
||||
cancelText: 'انجام نده',
|
||||
centered: true,
|
||||
async onOk() {
|
||||
stageId.value = nextId;
|
||||
if (isNew.value || !id.value) return;
|
||||
stageMoving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/deals/${id.value}/move`, { stageId: nextId });
|
||||
} catch (e) {
|
||||
stageId.value = prev;
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg)
|
||||
? msg.join('، ')
|
||||
: (msg ?? 'جابهجایی مرحله ناموفق بود.');
|
||||
} else {
|
||||
error.value = 'جابهجایی مرحله ناموفق بود.';
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
stageMoving.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
const productSelectOptions = computed(() =>
|
||||
products.value.map((p) => ({ value: p.id, label: p.name })),
|
||||
);
|
||||
@@ -665,6 +706,20 @@ onMounted(load);
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div
|
||||
v-if="stagePathItems.length"
|
||||
:style="{ marginBottom: '16px' }"
|
||||
>
|
||||
<StagePathStrip
|
||||
:stages="stagePathItems"
|
||||
:value="stageId"
|
||||
:loading="stageMoving"
|
||||
:disabled="saving || loading"
|
||||
aria-label="مراحل مسیر فروش"
|
||||
@select="onStageSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
|
||||
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
@@ -960,6 +1015,14 @@ onMounted(load);
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<RelatedActivitiesPanel
|
||||
v-if="!isNew && id"
|
||||
:style="{ marginTop: '16px' }"
|
||||
:deal-id="id"
|
||||
:person-id="personId || null"
|
||||
:company-id="companyId || null"
|
||||
:deal-title="title"
|
||||
/>
|
||||
<RelatedTasksPanel
|
||||
v-if="!isNew && id"
|
||||
:style="{ marginTop: '16px' }"
|
||||
|
||||
+442
-202
@@ -5,12 +5,10 @@ import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Badge as ABadge,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Drawer as ADrawer,
|
||||
Dropdown as ADropdown,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
@@ -41,6 +39,7 @@ import type { TableColumnsType, UploadProps } from 'ant-design-vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
EditOutlined,
|
||||
ExportOutlined,
|
||||
FileExcelOutlined,
|
||||
@@ -55,6 +54,7 @@ import { api } from '@/lib/api';
|
||||
import {
|
||||
formatJalaliNumeric,
|
||||
formatRelativeContact,
|
||||
parseLocalInput,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -298,8 +298,12 @@ const assigneeSelectValue = computed(() => {
|
||||
if (unassigned.value) return UNASSIGNED_VALUE;
|
||||
return assigneeId.value || undefined;
|
||||
});
|
||||
const moreFiltersCount = computed(() =>
|
||||
/** شمارش فیلترهای داخل پاپآور، مثل فعالیتها. */
|
||||
const mobileFilterBadgeCount = computed(() =>
|
||||
[
|
||||
stageId.value,
|
||||
assigneeSelectValue.value ? '1' : '',
|
||||
followUpFilter.value,
|
||||
cityFilter.value,
|
||||
regionFilter.value,
|
||||
tradeFilter.value,
|
||||
@@ -310,17 +314,226 @@ const moreFiltersCount = computed(() =>
|
||||
convertedFilter.value ? '1' : '',
|
||||
].filter(Boolean).length,
|
||||
);
|
||||
/** در موبایل مرتبسازی و مسئول و پیگیری هم داخل کشو هستند. */
|
||||
const mobileFilterBadgeCount = computed(() => {
|
||||
const extra = isMobile.value
|
||||
? [
|
||||
sort.value !== 'updated' ? '1' : '',
|
||||
assigneeSelectValue.value ? '1' : '',
|
||||
followUpFilter.value,
|
||||
].filter(Boolean).length
|
||||
: 0;
|
||||
return moreFiltersCount.value + extra;
|
||||
});
|
||||
const filterBtnLabel = computed(() =>
|
||||
mobileFilterBadgeCount.value
|
||||
? `فیلتر (${toFaDigits(mobileFilterBadgeCount.value)})`
|
||||
: 'فیلتر',
|
||||
);
|
||||
|
||||
type LeadFacetKey = 'assignee' | 'stage' | 'source' | 'followUp' | 'city' | 'region' | 'trade';
|
||||
const filterSection = ref<LeadFacetKey | 'createdFrom' | 'createdTo' | 'converted' | 'archived' | null>(
|
||||
null,
|
||||
);
|
||||
const filterQuery = ref('');
|
||||
const FACET_LIST_CAP = 40;
|
||||
|
||||
function normFilter(value: string) {
|
||||
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
|
||||
}
|
||||
|
||||
function toggleFilterSection(name: NonNullable<typeof filterSection.value>) {
|
||||
filterSection.value = filterSection.value === name ? null : name;
|
||||
filterQuery.value = '';
|
||||
}
|
||||
|
||||
const filterAnchor = ref<HTMLElement | null>(null);
|
||||
const filterPopoverMaxHeight = ref('min(70vh, 32rem)');
|
||||
|
||||
const filterPanelStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
top: 'calc(100% + 6px)',
|
||||
insetInlineStart: '0',
|
||||
zIndex: 1050,
|
||||
width: 'min(20rem, calc(100vw - 1.5rem))',
|
||||
maxHeight: filterPopoverMaxHeight.value,
|
||||
overflow: 'auto',
|
||||
background: token.value.colorBgElevated,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadowSecondary,
|
||||
padding: `${token.value.paddingSM}px`,
|
||||
}));
|
||||
|
||||
function placeFilterPopover() {
|
||||
const el = filterAnchor.value;
|
||||
if (!el) return;
|
||||
const space = Math.floor(window.innerHeight - el.getBoundingClientRect().bottom - 12);
|
||||
filterPopoverMaxHeight.value = `${Math.max(220, space)}px`;
|
||||
}
|
||||
|
||||
function onFilterDocPointer(event: Event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (filterAnchor.value?.contains(target)) return;
|
||||
if (document.getElementById('jalali-picker')?.contains(target)) return;
|
||||
moreFiltersOpen.value = false;
|
||||
filterSection.value = null;
|
||||
document.removeEventListener('pointerdown', onFilterDocPointer, true);
|
||||
}
|
||||
|
||||
function toggleFilterPopover() {
|
||||
if (moreFiltersOpen.value) {
|
||||
moreFiltersOpen.value = false;
|
||||
filterSection.value = null;
|
||||
document.removeEventListener('pointerdown', onFilterDocPointer, true);
|
||||
return;
|
||||
}
|
||||
placeFilterPopover();
|
||||
moreFiltersOpen.value = true;
|
||||
document.addEventListener('pointerdown', onFilterDocPointer, true);
|
||||
}
|
||||
|
||||
function dateFacetLabel(value: string) {
|
||||
if (!value) return 'همه';
|
||||
const parsed = parseLocalInput(value);
|
||||
return parsed ? formatJalaliNumeric(parsed) : value;
|
||||
}
|
||||
|
||||
function facetValueStyle(active: boolean) {
|
||||
return {
|
||||
color: active ? token.value.colorPrimary : token.value.colorTextQuaternary,
|
||||
fontWeight: active ? 700 : 400,
|
||||
maxWidth: '9.5rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const textFacets = computed(() => [
|
||||
{
|
||||
key: 'assignee' as const,
|
||||
label: 'کارشناس',
|
||||
value: unassigned.value
|
||||
? 'بدون کارشناس'
|
||||
: users.value.find((user) => user.id === assigneeId.value)?.fullName || 'همه',
|
||||
active: Boolean(assigneeSelectValue.value),
|
||||
current: assigneeSelectValue.value || '',
|
||||
searchable: true,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: UNASSIGNED_VALUE, label: 'بدون کارشناس' },
|
||||
...users.value.map((user) => ({ value: user.id, label: user.fullName })),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage' as const,
|
||||
label: 'مرحله',
|
||||
value: stages.value.find((stage) => stage.id === stageId.value)?.name || 'همه',
|
||||
active: Boolean(stageId.value),
|
||||
current: stageId.value,
|
||||
searchable: stages.value.length > 8,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...stages.value.map((stage) => ({ value: stage.id, label: stage.name })),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'source' as const,
|
||||
label: 'منبع',
|
||||
value: sourceFilter.value || 'همه',
|
||||
active: Boolean(sourceFilter.value),
|
||||
current: sourceFilter.value,
|
||||
searchable: sourceOptions.value.length > 8,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...sourceOptions.value.map((item) => ({ value: item, label: item })),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'followUp' as const,
|
||||
label: 'پیگیری',
|
||||
value:
|
||||
FOLLOW_UP_OPTIONS.find((item) => item.value === followUpFilter.value && item.value)?.label ||
|
||||
'همه',
|
||||
active: Boolean(followUpFilter.value),
|
||||
current: followUpFilter.value,
|
||||
searchable: false,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...FOLLOW_UP_OPTIONS.filter((item) => item.value).map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'city' as const,
|
||||
label: 'شهر',
|
||||
value: cityFilter.value || 'همه',
|
||||
active: Boolean(cityFilter.value),
|
||||
current: cityFilter.value,
|
||||
searchable: cityOptions.value.length > 8,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...cityOptions.value.map((item) => ({ value: item, label: item })),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'region' as const,
|
||||
label: 'منطقه',
|
||||
value: regionFilter.value || 'همه',
|
||||
active: Boolean(regionFilter.value),
|
||||
current: regionFilter.value,
|
||||
searchable: regionOptions.value.length > 8,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...regionOptions.value.map((item) => ({ value: item, label: item })),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'trade' as const,
|
||||
label: 'صنف',
|
||||
value: tradeFilter.value || 'همه',
|
||||
active: Boolean(tradeFilter.value),
|
||||
current: tradeFilter.value,
|
||||
searchable: tradeOptions.value.length > 8,
|
||||
options: [
|
||||
{ value: '', label: 'همه' },
|
||||
...tradeOptions.value.map((item) => ({ value: item, label: item })),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function visibleFacetOptions(options: Array<{ value: string; label: string }>) {
|
||||
const query = normFilter(filterQuery.value);
|
||||
const rest = options.filter(
|
||||
(item) => item.value !== '' && (!query || normFilter(item.label).includes(query)),
|
||||
);
|
||||
return {
|
||||
options: [...options.filter((item) => item.value === ''), ...rest.slice(0, FACET_LIST_CAP)],
|
||||
hidden: Math.max(0, rest.length - FACET_LIST_CAP),
|
||||
empty: Boolean(query) && rest.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pickFacet(key: LeadFacetKey, value: string) {
|
||||
const facet = textFacets.value.find((item) => item.key === key);
|
||||
const next = facet && facet.current === value ? '' : value;
|
||||
if (key === 'assignee') setAssigneeFilter(next || undefined);
|
||||
else if (key === 'stage') setStageFilter(next);
|
||||
else if (key === 'source') setSourceFilter(next);
|
||||
else if (key === 'followUp') setFollowUpFilter(next || undefined);
|
||||
else if (key === 'city') setCityFilter(next);
|
||||
else if (key === 'region') setRegionFilter(next);
|
||||
else if (key === 'trade') setTradeFilter(next);
|
||||
filterSection.value = null;
|
||||
}
|
||||
|
||||
function pickFlag(which: 'converted' | 'archived', on: boolean) {
|
||||
const current = which === 'converted' ? convertedFilter.value : archivedFilter.value;
|
||||
const next = on ? !current : false;
|
||||
if (which === 'converted') setConvertedFilter(next);
|
||||
else setArchivedFilter(next);
|
||||
filterSection.value = null;
|
||||
}
|
||||
|
||||
function facetOptionStyle(selected: boolean) {
|
||||
return {
|
||||
textAlign: 'start' as const,
|
||||
background: selected ? token.value.colorPrimaryBg : 'transparent',
|
||||
color: selected ? token.value.colorPrimary : token.value.colorText,
|
||||
};
|
||||
}
|
||||
|
||||
const total = computed(() => data.value?.total ?? 0);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||
@@ -647,6 +860,10 @@ function setConvertedFilter(checked: boolean) {
|
||||
|
||||
function clearMoreFilters() {
|
||||
patchQuery({
|
||||
stageId: undefined,
|
||||
assigneeId: undefined,
|
||||
unassigned: undefined,
|
||||
followUp: undefined,
|
||||
city: undefined,
|
||||
region: undefined,
|
||||
trade: undefined,
|
||||
@@ -655,16 +872,9 @@ function clearMoreFilters() {
|
||||
createdTo: undefined,
|
||||
archived: undefined,
|
||||
converted: undefined,
|
||||
...(isMobile.value
|
||||
? {
|
||||
sort: undefined,
|
||||
assigneeId: undefined,
|
||||
unassigned: undefined,
|
||||
followUp: undefined,
|
||||
}
|
||||
: {}),
|
||||
page: undefined,
|
||||
});
|
||||
filterSection.value = null;
|
||||
}
|
||||
|
||||
function setPage(next: number) {
|
||||
@@ -1342,11 +1552,12 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
stopImportPoll();
|
||||
revokeLogoUrls();
|
||||
document.removeEventListener('pointerdown', onFilterDocPointer, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-row :gutter="[16, 16]" @click="closePopups">
|
||||
<a-row :gutter="[16, 16]" align="top" :style="{ alignContent: 'flex-start' }" @click="closePopups">
|
||||
<a-col :span="24">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
||||
@@ -1507,12 +1718,12 @@ onBeforeUnmount(() => {
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-flex v-if="isMobile" vertical :gap="8">
|
||||
<a-flex :vertical="isMobile" align="center" wrap="wrap" :gap="8">
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، موبایل، شهر…"
|
||||
style="width: 100%"
|
||||
:style="isMobile ? { width: '100%' } : { width: '15rem', maxWidth: '100%' }"
|
||||
@search="onSearch"
|
||||
>
|
||||
<template #enterButton>
|
||||
@@ -1521,195 +1732,224 @@ onBeforeUnmount(() => {
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input-search>
|
||||
<a-badge
|
||||
:count="mobileFilterBadgeCount"
|
||||
:offset="[-2, 2]"
|
||||
:style="{ width: '100%', display: 'block' }"
|
||||
<div
|
||||
ref="filterAnchor"
|
||||
:style="{ position: 'relative', width: isMobile ? '100%' : undefined }"
|
||||
>
|
||||
<a-button block @click="moreFiltersOpen = true">
|
||||
<template #icon><FilterOutlined /></template>
|
||||
فیلترها
|
||||
<a-button
|
||||
:block="isMobile"
|
||||
:style="mobileFilterBadgeCount
|
||||
? {
|
||||
borderColor: token.colorPrimary,
|
||||
color: token.colorPrimary,
|
||||
background: token.colorPrimaryBg,
|
||||
}
|
||||
: undefined"
|
||||
@click.stop="toggleFilterPopover"
|
||||
>
|
||||
<a-flex align="center" justify="center" :gap="6">
|
||||
<FilterOutlined />
|
||||
<span>{{ filterBtnLabel }}</span>
|
||||
<DownOutlined />
|
||||
</a-flex>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</a-flex>
|
||||
<a-flex v-else align="center" wrap="wrap" :gap="8">
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو نام، موبایل، شهر…"
|
||||
style="width: 15rem; max-width: 100%"
|
||||
@search="onSearch"
|
||||
>
|
||||
<template #enterButton>
|
||||
<a-button type="primary">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input-search>
|
||||
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]">
|
||||
<a-button @click="moreFiltersOpen = true">
|
||||
<template #icon><FilterOutlined /></template>
|
||||
فیلترها
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<div v-if="moreFiltersOpen" data-lead-filter :style="filterPanelStyle" @click.stop>
|
||||
<a-flex vertical :gap="10">
|
||||
<div v-for="facet in textFacets" :key="facet.key">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="toggleFilterSection(facet.key)"
|
||||
@keydown.enter.prevent="toggleFilterSection(facet.key)"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<span>{{ facet.label }}</span>
|
||||
<a-typography-text :style="facetValueStyle(facet.active)">
|
||||
{{ facet.value }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-flex
|
||||
v-if="filterSection === facet.key"
|
||||
vertical
|
||||
:gap="4"
|
||||
:style="{ marginTop: '6px' }"
|
||||
>
|
||||
<a-input
|
||||
v-if="facet.searchable"
|
||||
v-model:value="filterQuery"
|
||||
allow-clear
|
||||
placeholder="جستجو"
|
||||
/>
|
||||
<div :style="{ maxHeight: '11.5rem', overflow: 'auto' }">
|
||||
<a-button
|
||||
v-for="option in visibleFacetOptions(facet.options).options"
|
||||
:key="option.value || 'all'"
|
||||
type="text"
|
||||
block
|
||||
:style="facetOptionStyle(facet.current === option.value)"
|
||||
@click="pickFacet(facet.key, option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-button>
|
||||
<a-typography-text
|
||||
v-if="visibleFacetOptions(facet.options).empty"
|
||||
type="secondary"
|
||||
>
|
||||
موردی پیدا نشد
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text
|
||||
v-if="visibleFacetOptions(facet.options).hidden"
|
||||
type="secondary"
|
||||
>
|
||||
برای دیدن بقیه، نام را دقیقتر بنویسید
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="toggleFilterSection('createdFrom')"
|
||||
@keydown.enter.prevent="toggleFilterSection('createdFrom')"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<span>از تاریخ ثبت</span>
|
||||
<a-typography-text :style="facetValueStyle(Boolean(createdFromFilter))">
|
||||
{{ dateFacetLabel(createdFromFilter) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
<div v-if="filterSection === 'createdFrom'" :style="{ marginTop: '6px' }">
|
||||
<JalaliDateInput
|
||||
:model-value="createdFromFilter"
|
||||
placeholder="از تاریخ"
|
||||
@update:model-value="(value: string) => { setCreatedFromFilter(value); filterSection = null }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="toggleFilterSection('createdTo')"
|
||||
@keydown.enter.prevent="toggleFilterSection('createdTo')"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<span>تا تاریخ ثبت</span>
|
||||
<a-typography-text :style="facetValueStyle(Boolean(createdToFilter))">
|
||||
{{ dateFacetLabel(createdToFilter) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
<div v-if="filterSection === 'createdTo'" :style="{ marginTop: '6px' }">
|
||||
<JalaliDateInput
|
||||
:model-value="createdToFilter"
|
||||
placeholder="تا تاریخ"
|
||||
@update:model-value="(value: string) => { setCreatedToFilter(value); filterSection = null }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="toggleFilterSection('converted')"
|
||||
@keydown.enter.prevent="toggleFilterSection('converted')"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<span>تبدیلشده</span>
|
||||
<a-typography-text :style="facetValueStyle(convertedFilter)">
|
||||
{{ convertedFilter ? 'فقط' : 'همه' }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-flex v-if="filterSection === 'converted'" vertical :style="{ marginTop: '6px' }">
|
||||
<a-button
|
||||
type="text"
|
||||
block
|
||||
:style="facetOptionStyle(!convertedFilter)"
|
||||
@click="pickFlag('converted', false)"
|
||||
>
|
||||
همه
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
block
|
||||
:style="facetOptionStyle(convertedFilter)"
|
||||
@click="pickFlag('converted', true)"
|
||||
>
|
||||
فقط تبدیلشدهها
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:style="{ cursor: 'pointer' }"
|
||||
@click="toggleFilterSection('archived')"
|
||||
@keydown.enter.prevent="toggleFilterSection('archived')"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<span>بایگانی</span>
|
||||
<a-typography-text :style="facetValueStyle(archivedFilter)">
|
||||
{{ archivedFilter ? 'فقط' : 'همه' }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-flex v-if="filterSection === 'archived'" vertical :style="{ marginTop: '6px' }">
|
||||
<a-button
|
||||
type="text"
|
||||
block
|
||||
:style="facetOptionStyle(!archivedFilter)"
|
||||
@click="pickFlag('archived', false)"
|
||||
>
|
||||
همه
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
block
|
||||
:style="facetOptionStyle(archivedFilter)"
|
||||
@click="pickFlag('archived', true)"
|
||||
>
|
||||
فقط بایگانیشدهها
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
v-if="mobileFilterBadgeCount"
|
||||
type="text"
|
||||
danger
|
||||
block
|
||||
@click="clearMoreFilters"
|
||||
>
|
||||
پاک کردن فیلترها
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</div>
|
||||
</div>
|
||||
<a-select
|
||||
:value="sort"
|
||||
style="width: 10rem"
|
||||
:style="isMobile ? { width: '100%' } : { width: '10rem' }"
|
||||
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||
/>
|
||||
<a-select
|
||||
:value="assigneeSelectValue"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="مسئول"
|
||||
style="width: 10rem"
|
||||
:options="[
|
||||
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
||||
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]"
|
||||
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
||||
/>
|
||||
<a-select
|
||||
:value="followUpFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="پیگیری"
|
||||
style="width: 13rem"
|
||||
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
||||
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="moreFiltersOpen"
|
||||
:title="isMobile ? 'فیلترها' : 'فیلترهای بیشتر'"
|
||||
placement="left"
|
||||
:width="isMobile ? '100%' : 380"
|
||||
:destroy-on-close="false"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<template v-if="isMobile">
|
||||
<a-form-item label="مرتبسازی">
|
||||
<a-select
|
||||
:value="sort"
|
||||
style="width: 100%"
|
||||
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="مسئول">
|
||||
<a-select
|
||||
:value="assigneeSelectValue"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="همه مسئولان"
|
||||
style="width: 100%"
|
||||
:options="[
|
||||
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
||||
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
]"
|
||||
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="پیگیری">
|
||||
<a-select
|
||||
:value="followUpFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="همه پیگیریها"
|
||||
style="width: 100%"
|
||||
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
||||
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
:value="cityFilter || undefined"
|
||||
allow-clear
|
||||
show-search
|
||||
placeholder="همه شهرها"
|
||||
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
||||
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="منطقه">
|
||||
<a-select
|
||||
:value="regionFilter || undefined"
|
||||
allow-clear
|
||||
show-search
|
||||
placeholder="همه مناطق"
|
||||
:options="regionOptions.map((r) => ({ value: r, label: r }))"
|
||||
@update:value="(v) => setRegionFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="صنف">
|
||||
<a-select
|
||||
:value="tradeFilter || undefined"
|
||||
allow-clear
|
||||
show-search
|
||||
placeholder="همه اصناف"
|
||||
:options="tradeOptions.map((t) => ({ value: t, label: t }))"
|
||||
@update:value="(v) => setTradeFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="منبع">
|
||||
<a-select
|
||||
:value="sourceFilter || undefined"
|
||||
allow-clear
|
||||
show-search
|
||||
placeholder="همه منابع"
|
||||
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
||||
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="از تاریخ ثبت">
|
||||
<JalaliDateInput
|
||||
:model-value="createdFromFilter"
|
||||
placeholder="از تاریخ"
|
||||
@update:model-value="setCreatedFromFilter"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="تا تاریخ ثبت">
|
||||
<JalaliDateInput
|
||||
:model-value="createdToFilter"
|
||||
placeholder="تا تاریخ"
|
||||
@update:model-value="setCreatedToFilter"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox
|
||||
:checked="convertedFilter"
|
||||
@update:checked="setConvertedFilter"
|
||||
>
|
||||
فقط تبدیلشدهها
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-checkbox
|
||||
:checked="archivedFilter"
|
||||
@update:checked="setArchivedFilter"
|
||||
>
|
||||
فقط بایگانیشدهها
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button :disabled="!mobileFilterBadgeCount" @click="clearMoreFilters">
|
||||
پاک کردن
|
||||
</a-button>
|
||||
<a-button type="primary" @click="moreFiltersOpen = false">
|
||||
بستن
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<a-col v-if="selectedIds.length" :span="24" @click.stop>
|
||||
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
|
||||
<a-space :size="8" wrap>
|
||||
|
||||
@@ -1906,7 +1906,11 @@ onBeforeUnmount(() => {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #0f172a;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -1918,7 +1922,7 @@ onBeforeUnmount(() => {
|
||||
color: #334155;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
flex: 1 1 2.9em;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -1953,13 +1957,15 @@ onBeforeUnmount(() => {
|
||||
margin-top: 0.45rem;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
flex: 0 1 72px;
|
||||
min-height: 0;
|
||||
max-height: 72px;
|
||||
}
|
||||
.card-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -2176,11 +2182,16 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,9 @@ const website = ref('');
|
||||
const instagram = ref('');
|
||||
const birthDate = ref('');
|
||||
const referrer = ref('');
|
||||
const nationalId = ref('');
|
||||
const city = ref('');
|
||||
const rasteh = ref('');
|
||||
const addresses = ref<string[]>(['']);
|
||||
const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
@@ -283,6 +286,16 @@ function fill(p: PersonDto) {
|
||||
mood.value = p.mood || 'newcomer';
|
||||
groupIds.value = (p.groups ?? []).map((g) => g.id);
|
||||
customFields.value = { ...(p.customFields ?? {}) };
|
||||
const cf = customFields.value;
|
||||
nationalId.value =
|
||||
typeof cf.nationalId === 'string'
|
||||
? cf.nationalId
|
||||
: typeof cf.nationalId === 'number'
|
||||
? String(cf.nationalId)
|
||||
: '';
|
||||
city.value = typeof cf.city === 'string' ? cf.city : '';
|
||||
rasteh.value =
|
||||
typeof cf.rasteh === 'string' || typeof cf.rasteh === 'number' ? String(cf.rasteh) : '';
|
||||
loyaltyPoints.value = p.loyaltyPoints ?? 0;
|
||||
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
|
||||
}
|
||||
@@ -344,6 +357,18 @@ function parseTags(raw: string): string[] {
|
||||
}
|
||||
|
||||
function payload(): CreatePersonRequest {
|
||||
const fields: Record<string, unknown> = { ...customFields.value };
|
||||
const nid = nationalId.value.trim();
|
||||
if (nid) fields.nationalId = nid;
|
||||
else delete fields.nationalId;
|
||||
const cityVal = city.value.trim();
|
||||
if (cityVal) fields.city = cityVal;
|
||||
else delete fields.city;
|
||||
const rastehRaw = rasteh.value.trim();
|
||||
if (rastehRaw) {
|
||||
const n = Number(rastehRaw);
|
||||
fields.rasteh = Number.isFinite(n) && String(n) === rastehRaw ? n : rastehRaw;
|
||||
} else delete fields.rasteh;
|
||||
return {
|
||||
firstName: firstName.value,
|
||||
lastName: lastName.value,
|
||||
@@ -363,7 +388,7 @@ function payload(): CreatePersonRequest {
|
||||
companyId: companyId.value || null,
|
||||
mood: mood.value,
|
||||
groupIds: groupIds.value,
|
||||
customFields: customFields.value,
|
||||
customFields: fields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -545,6 +570,21 @@ onMounted(load);
|
||||
<a-input v-model:value="referrer" placeholder="نام معرف" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کد ملی">
|
||||
<a-input v-model:value="nationalId" dir="ltr" placeholder="کد ملی (تهحساب)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شهر">
|
||||
<a-input v-model:value="city" placeholder="شهر" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="رسته">
|
||||
<a-input v-model:value="rasteh" dir="ltr" placeholder="رسته حساب تهحساب" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="right">آدرسها</a-divider>
|
||||
|
||||
@@ -183,6 +183,18 @@ const externalCode = computed(() => {
|
||||
}
|
||||
return '';
|
||||
});
|
||||
function customFieldText(...keys: string[]) {
|
||||
const fields = person.value?.customFields ?? {};
|
||||
for (const key of keys) {
|
||||
const raw = fields[key];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const nationalIdLabel = computed(() => customFieldText('nationalId', 'CodeMelli', 'codeMeli'));
|
||||
const cityLabel = computed(() => customFieldText('city', 'City'));
|
||||
const rastehLabel = computed(() => customFieldText('rasteh', 'Rasteh'));
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
manual: 'دستی',
|
||||
tahesab: 'تهحساب',
|
||||
@@ -444,6 +456,9 @@ const infoEmpty = computed(() => {
|
||||
!p.instagram &&
|
||||
!p.birthDate &&
|
||||
!p.referrer &&
|
||||
!nationalIdLabel.value &&
|
||||
!cityLabel.value &&
|
||||
!rastehLabel.value &&
|
||||
!(p.addresses?.length) &&
|
||||
!p.company &&
|
||||
!externalCode.value &&
|
||||
@@ -794,6 +809,18 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
<dt>معرف</dt>
|
||||
<dd>{{ person.referrer }}</dd>
|
||||
</div>
|
||||
<div v-if="nationalIdLabel" class="fact">
|
||||
<dt>کد ملی</dt>
|
||||
<dd dir="ltr">{{ toFaDigits(nationalIdLabel) }}</dd>
|
||||
</div>
|
||||
<div v-if="cityLabel" class="fact">
|
||||
<dt>شهر</dt>
|
||||
<dd>{{ cityLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="rastehLabel" class="fact">
|
||||
<dt>رسته</dt>
|
||||
<dd dir="ltr">{{ toFaDigits(rastehLabel) }}</dd>
|
||||
</div>
|
||||
<div v-if="person.website" class="fact">
|
||||
<dt>وبسایت</dt>
|
||||
<dd dir="ltr">{{ person.website }}</dd>
|
||||
@@ -1119,6 +1146,15 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
|
||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||
<div>
|
||||
<dt>کد ملی</dt>
|
||||
<dd dir="ltr">{{ nationalIdLabel ? toFaDigits(nationalIdLabel) : '—' }}</dd>
|
||||
</div>
|
||||
<div><dt>شهر</dt><dd>{{ cityLabel || '—' }}</dd></div>
|
||||
<div>
|
||||
<dt>رسته</dt>
|
||||
<dd dir="ltr">{{ rastehLabel ? toFaDigits(rastehLabel) : '—' }}</dd>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<dt>آدرسها</dt>
|
||||
<dd>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
|
||||
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
@@ -108,6 +108,7 @@ const overlayAccent = ref<PipelineStage['color']>('green');
|
||||
const overlayPos = ref({ x: 0, y: 0 });
|
||||
const menuOpenId = ref<string | null>(null);
|
||||
const moving = ref(false);
|
||||
let dragSessionId: string | null = null;
|
||||
|
||||
const pipelineOptions = computed(() =>
|
||||
pipelines.value.map((p) => ({
|
||||
@@ -164,9 +165,7 @@ function applyDealsSavedFilters(filters: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function formatDealDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return formatJalaliNumeric(d);
|
||||
return formatRelativeDayLabel(iso);
|
||||
}
|
||||
|
||||
function findAccent(dealId: string): PipelineStage['color'] {
|
||||
@@ -177,7 +176,9 @@ function findAccent(dealId: string): PipelineStage['color'] {
|
||||
}
|
||||
|
||||
async function loadPipelines() {
|
||||
const { data } = await api.get<PipelineDto[]>('/pipelines');
|
||||
const { data } = await api.get<PipelineDto[]>('/pipelines', {
|
||||
params: { forLens: '1' },
|
||||
});
|
||||
pipelines.value = data;
|
||||
}
|
||||
|
||||
@@ -186,16 +187,23 @@ async function load() {
|
||||
error.value = '';
|
||||
try {
|
||||
await loadPipelines();
|
||||
const pipelineId =
|
||||
typeof route.query.pipelineId === 'string'
|
||||
? route.query.pipelineId
|
||||
: undefined;
|
||||
const requested =
|
||||
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : '';
|
||||
const pipelineId = pipelines.value.some((item) => item.id === requested)
|
||||
? requested
|
||||
: pipelines.value[0]?.id;
|
||||
if (!pipelineId) {
|
||||
boardPipelineId.value = '';
|
||||
stages.value = [];
|
||||
if (requested) await router.replace({ name: 'pipeline', query: {} });
|
||||
return;
|
||||
}
|
||||
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
|
||||
params: pipelineId ? { pipelineId } : undefined,
|
||||
params: { pipelineId },
|
||||
});
|
||||
boardPipelineId.value = data.pipeline.id;
|
||||
stages.value = mapBoardToStages(data, formatDealDate);
|
||||
if (!pipelineId || pipelineId !== data.pipeline.id) {
|
||||
if (requested !== data.pipeline.id) {
|
||||
await router.replace({
|
||||
name: 'pipeline',
|
||||
query: { pipelineId: data.pipeline.id },
|
||||
@@ -235,13 +243,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
|
||||
}
|
||||
|
||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
overlayAccent.value = findAccent(deal.id);
|
||||
overlayPos.value = {
|
||||
x: (event.clientX || 0) + 12,
|
||||
y: (event.clientY || 0) + 12,
|
||||
};
|
||||
draggingColumnId.value = null;
|
||||
if (moving.value) moving.value = false;
|
||||
dragSessionId = deal.id;
|
||||
event.dataTransfer?.setData('text/plain', deal.id);
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
if (event.dataTransfer) {
|
||||
@@ -250,12 +254,29 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
event.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
overlayAccent.value = findAccent(deal.id);
|
||||
overlayPos.value = {
|
||||
x: (event.clientX || 0) + 12,
|
||||
y: (event.clientY || 0) + 12,
|
||||
};
|
||||
// DOM را بعد از dragstart عوض کن تا کروم درگ را لغو نکند
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId !== deal.id) return;
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
const endedId = dragSessionId ?? draggingId.value;
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId && dragSessionId !== endedId) return;
|
||||
if (draggingId.value && draggingId.value !== endedId) return;
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
@@ -295,37 +316,45 @@ async function applyUpdateDealStage(
|
||||
|
||||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
const raw =
|
||||
event.dataTransfer?.getData('text/plain') ||
|
||||
draggingColumnId.value ||
|
||||
draggingId.value ||
|
||||
'';
|
||||
const transfer = event.dataTransfer?.getData('text/plain') || '';
|
||||
const draggedCardId = dragSessionId || draggingId.value;
|
||||
const columnId = draggingColumnId.value;
|
||||
dropStageId.value = null;
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
||||
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
||||
draggingColumnId.value = null;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
draggingColumnId.value = null;
|
||||
|
||||
// کارت بر payload ستون اولویت دارد
|
||||
const dealId =
|
||||
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
|
||||
if (dealId) {
|
||||
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
|
||||
if (current?.id === stageId && index == null) return;
|
||||
|
||||
const next = updateDealStage(stages.value, dealId, stageId, index);
|
||||
const unchanged = next.every((stage, stageIndex) => {
|
||||
const prev = stages.value[stageIndex];
|
||||
return (
|
||||
stage.deals.length === (prev?.deals.length ?? -1) &&
|
||||
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
|
||||
);
|
||||
});
|
||||
if (unchanged) return;
|
||||
|
||||
// اگر جابهجایی قبلی هنوز در جریان است، این یکی را صف نکن — فقط رد نکن بیصدا با early return قدیمی
|
||||
if (moving.value) {
|
||||
moving.value = false;
|
||||
}
|
||||
await applyUpdateDealStage(dealId, stageId, index);
|
||||
return;
|
||||
}
|
||||
const dealId = raw;
|
||||
if (!dealId || moving.value) return;
|
||||
|
||||
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
|
||||
if (current?.id === stageId && index == null) return;
|
||||
|
||||
const next = updateDealStage(stages.value, dealId, stageId, index);
|
||||
const unchanged = next.every((stage, stageIndex) => {
|
||||
const prev = stages.value[stageIndex];
|
||||
return (
|
||||
stage.deals.length === (prev?.deals.length ?? -1) &&
|
||||
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
|
||||
);
|
||||
});
|
||||
if (unchanged) return;
|
||||
|
||||
await applyUpdateDealStage(dealId, stageId, index);
|
||||
const isStageDrag = transfer.startsWith('stage:');
|
||||
if (isStageDrag || columnId) {
|
||||
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
}
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
@@ -342,6 +371,8 @@ function onAddDeal(stageId: string) {
|
||||
}
|
||||
|
||||
function onColumnDragStart(stageId: string) {
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = stageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
|
||||
const draggingId = ref<string | null>(null);
|
||||
let dragSessionId: string | null = null;
|
||||
const draggingColumnId = ref<string | null>(null);
|
||||
const dropStageId = ref<string | null>(null);
|
||||
const overlayDeal = ref<PipelineDeal | null>(null);
|
||||
@@ -177,13 +178,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
|
||||
}
|
||||
|
||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
overlayAccent.value = findAccent(deal.id);
|
||||
overlayPos.value = {
|
||||
x: (event.clientX || 0) + 12,
|
||||
y: (event.clientY || 0) + 12,
|
||||
};
|
||||
draggingColumnId.value = null;
|
||||
if (moving.value) moving.value = false;
|
||||
dragSessionId = deal.id;
|
||||
event.dataTransfer?.setData('text/plain', deal.id);
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
if (event.dataTransfer) {
|
||||
@@ -192,12 +189,28 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
event.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
overlayAccent.value = findAccent(deal.id);
|
||||
overlayPos.value = {
|
||||
x: (event.clientX || 0) + 12,
|
||||
y: (event.clientY || 0) + 12,
|
||||
};
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId !== deal.id) return;
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
const endedId = dragSessionId ?? draggingId.value;
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId && dragSessionId !== endedId) return;
|
||||
if (draggingId.value && draggingId.value !== endedId) return;
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
@@ -237,30 +250,33 @@ async function applyUpdateCardStage(
|
||||
|
||||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
const raw =
|
||||
event.dataTransfer?.getData('text/plain') ||
|
||||
draggingColumnId.value ||
|
||||
draggingId.value ||
|
||||
'';
|
||||
const transfer = event.dataTransfer?.getData('text/plain') || '';
|
||||
const draggedCardId = dragSessionId || draggingId.value;
|
||||
const columnId = draggingColumnId.value;
|
||||
dropStageId.value = null;
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
||||
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
||||
draggingColumnId.value = null;
|
||||
draggingColumnId.value = null;
|
||||
|
||||
const cardId =
|
||||
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
|
||||
if (cardId) {
|
||||
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
|
||||
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
|
||||
if (current?.id === stageId && (index == null || index === currentIndex)) {
|
||||
return;
|
||||
}
|
||||
if (moving.value) moving.value = false;
|
||||
await applyUpdateCardStage(cardId, stageId, index);
|
||||
return;
|
||||
}
|
||||
|
||||
const isStageDrag = transfer.startsWith('stage:');
|
||||
if (isStageDrag || columnId) {
|
||||
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
return;
|
||||
}
|
||||
const cardId = raw;
|
||||
if (!cardId || moving.value) return;
|
||||
|
||||
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
|
||||
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
|
||||
if (current?.id === stageId && (index == null || index === currentIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyUpdateCardStage(cardId, stageId, index);
|
||||
}
|
||||
|
||||
function onAddCard(stageId: string) {
|
||||
@@ -268,6 +284,8 @@ function onAddCard(stageId: string) {
|
||||
}
|
||||
|
||||
function onColumnDragStart(stageId: string) {
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = stageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ type SyncResult = {
|
||||
alreadyLinked?: boolean;
|
||||
personId?: string | null;
|
||||
personName?: string | null;
|
||||
duplicateOfCode?: number | null;
|
||||
duplicatePersonId?: string | null;
|
||||
duplicatePersonName?: string | null;
|
||||
};
|
||||
const syncResults = ref<SyncResult[]>([]);
|
||||
/** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشتسرهم، خواندن تمام میشود */
|
||||
@@ -145,9 +148,96 @@ async function saveAutoSync() {
|
||||
autoSyncSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** همهٔ مشتریان تهحساب را میخواند: تازهها وارد، لینکشدهها پروفایلشان بهروز میشود */
|
||||
async function syncCustomersNow() {
|
||||
if (!sync.connectionId) {
|
||||
error.value = 'اتصال را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
syncBusy.value = true;
|
||||
importOverlay.open = true;
|
||||
importOverlay.progress = null;
|
||||
importOverlay.message =
|
||||
'در حال همگامسازی همهٔ مشتریان… نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه بهروز میشود.';
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const { data } = await api.post<{
|
||||
imported: number;
|
||||
updated: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
summary: string;
|
||||
error: string | null;
|
||||
}>('/integrations/tahesab/sync/customers-now', {
|
||||
connectionId: sync.connectionId,
|
||||
});
|
||||
ok.value = data.summary;
|
||||
if (data.error) error.value = data.error;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'همگامسازی مشتریان ناموفق بود');
|
||||
} finally {
|
||||
syncBusy.value = false;
|
||||
importOverlay.open = false;
|
||||
}
|
||||
}
|
||||
/** همان قاعدهٔ سرور: موبایل ایران 09… است، بقیهٔ شمارهها فقط رقماند. */
|
||||
function customerPhoneKey(value?: string | null): string {
|
||||
const fa = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ar = '٠١٢٣٤٥٦٧٨٩';
|
||||
let digits = String(value ?? '')
|
||||
.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
const faIndex = fa.indexOf(ch);
|
||||
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
|
||||
})
|
||||
.replace(/\D/g, '');
|
||||
if (!digits) return '';
|
||||
if (digits.startsWith('0098') && digits.length >= 14) digits = digits.slice(4);
|
||||
else if (digits.startsWith('98') && digits.length >= 12) digits = digits.slice(2);
|
||||
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
const linkedPhoneOwners = computed(() => {
|
||||
const owners = new Map<string, { code: number; personId: string | null; personName: string | null }>();
|
||||
for (const row of syncResults.value) {
|
||||
if (!row.alreadyLinked || row.moshtariCode == null) continue;
|
||||
const key = customerPhoneKey(row.tel);
|
||||
if (key && !owners.has(key)) {
|
||||
owners.set(key, {
|
||||
code: row.moshtariCode,
|
||||
personId: row.personId ?? null,
|
||||
personName: row.personName ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
});
|
||||
|
||||
function duplicateOwner(row: SyncResult) {
|
||||
if (row.alreadyLinked || row.moshtariCode == null) return null;
|
||||
if (row.duplicateOfCode != null) {
|
||||
return {
|
||||
code: row.duplicateOfCode,
|
||||
personId: row.duplicatePersonId ?? null,
|
||||
personName: row.duplicatePersonName ?? null,
|
||||
};
|
||||
}
|
||||
const key = customerPhoneKey(row.tel);
|
||||
if (!key) return null;
|
||||
const owner = linkedPhoneOwners.value.get(key);
|
||||
if (!owner || owner.code === row.moshtariCode) return null;
|
||||
return owner;
|
||||
}
|
||||
|
||||
const duplicateCustomerCount = computed(
|
||||
() => syncResults.value.filter((row) => duplicateOwner(row)).length,
|
||||
);
|
||||
const syncVisibleResults = computed(() =>
|
||||
syncOnlyNew.value
|
||||
? syncResults.value.filter((r) => !r.alreadyLinked)
|
||||
? syncResults.value.filter((row) => !row.alreadyLinked && !duplicateOwner(row))
|
||||
: syncResults.value,
|
||||
);
|
||||
const syncTableTotalPages = computed(() =>
|
||||
@@ -184,7 +274,7 @@ const productCategoryLabel = ref('کارساخته');
|
||||
|
||||
function pendingImportCodes() {
|
||||
return syncResults.value
|
||||
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
|
||||
.filter((r) => r.moshtariCode != null && !r.alreadyLinked && !duplicateOwner(r))
|
||||
.map((r) => r.moshtariCode as number);
|
||||
}
|
||||
|
||||
@@ -458,10 +548,12 @@ async function searchTahesab(mode: 'range' | 'filter') {
|
||||
ok.value = 'نتیجهای یافت نشد.';
|
||||
} else {
|
||||
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
|
||||
const pending = syncResults.value.length - linked;
|
||||
const duplicate = syncResults.value.filter((r) => duplicateOwner(r)).length;
|
||||
const pending = syncResults.value.length - linked - duplicate;
|
||||
ok.value =
|
||||
`${toFaDigits(syncResults.value.length)} مشتری پیدا شد` +
|
||||
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`;
|
||||
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده` +
|
||||
(duplicate ? ` · ${toFaDigits(duplicate)} شماره تکراری` : '');
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'جستجو ناموفق');
|
||||
@@ -951,9 +1043,9 @@ onMounted(load);
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">بروزرسانی خودکار</h4>
|
||||
<p class="sync-sub-desc">
|
||||
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعتها، به وقت تهران،
|
||||
مشتریان تازه را وارد میکند و نام مشتریهای قبلی را هم با تهحساب تازه میکند. اگر شماره یکی باشد
|
||||
همان مشتری میماند، نام قبلی حفظ میشود و شماره به شکل ۰۹۱۲ ذخیره میشود. خاموش یعنی فقط ورود دستی.
|
||||
«همگامسازی الان» همهٔ مشتریان تهحساب را میخواند: تازهها را وارد میکند و برای مشتریهای از قبل
|
||||
لینکشده نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه را از تهحساب تازه میکند. زمانبندی زیر
|
||||
همان کار را در ساعتهای انتخابی (به وقت تهران) تکرار میکند. خاموش یعنی فقط ورود دستی.
|
||||
</p>
|
||||
<UiField label="تعداد در روز">
|
||||
<UiSelect
|
||||
@@ -986,8 +1078,16 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="sync-buttons">
|
||||
<UiButton
|
||||
:loading="syncBusy"
|
||||
:disabled="!sync.connectionId || syncBusy || autoSyncSaving"
|
||||
@click="syncCustomersNow"
|
||||
>
|
||||
همگامسازی الان (وارد + بهروز کردن قبلیها)
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:loading="autoSyncSaving"
|
||||
:disabled="!sync.connectionId"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="saveAutoSync"
|
||||
>
|
||||
ذخیره زمانبندی
|
||||
@@ -1100,6 +1200,9 @@ onMounted(load);
|
||||
<h4 class="sync-results-title">
|
||||
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
|
||||
</h4>
|
||||
<p v-if="duplicateCustomerCount" class="sync-sub-desc">
|
||||
{{ toFaDigits(duplicateCustomerCount) }} مشتری شمارهٔ تکراری دارند و جداگانه وارد نمیشوند؛ همان شخص با کد دیگری از قبل لینک است.
|
||||
</p>
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="tahesab.customers" align="end" />
|
||||
</div>
|
||||
@@ -1120,7 +1223,9 @@ onMounted(load);
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!syncPagedResults.length">
|
||||
<td colspan="5" class="sync-empty-row">همهٔ مشتریان این نتایج قبلاً وارد شدهاند.</td>
|
||||
<td colspan="5" class="sync-empty-row">
|
||||
مشتری تازهای برای ورود نیست. شمارههای تکراری از قبل با کد دیگری لینک شدهاند.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="r in syncPagedResults" :key="String(r.moshtariCode)">
|
||||
<td class="font-mono">{{ r.moshtariCode }}</td>
|
||||
@@ -1130,11 +1235,14 @@ onMounted(load);
|
||||
<UiBadge v-if="r.alreadyLinked" variant="success">
|
||||
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
|
||||
</UiBadge>
|
||||
<UiBadge v-else-if="duplicateOwner(r)" variant="warning">
|
||||
شماره تکراری، کد {{ toFaDigits(duplicateOwner(r)!.code) }}
|
||||
</UiBadge>
|
||||
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<UiButton
|
||||
v-if="!r.alreadyLinked"
|
||||
v-if="!r.alreadyLinked && !duplicateOwner(r)"
|
||||
size="sm"
|
||||
:disabled="!r.moshtariCode || syncBusy"
|
||||
@click="importOne(r.moshtariCode!)"
|
||||
@@ -1142,8 +1250,8 @@ onMounted(load);
|
||||
وارد کردن
|
||||
</UiButton>
|
||||
<RouterLink
|
||||
v-else-if="r.personId"
|
||||
:to="{ name: 'person-detail', params: { id: r.personId } }"
|
||||
v-else-if="r.personId || duplicateOwner(r)?.personId"
|
||||
:to="{ name: 'person-detail', params: { id: (r.personId || duplicateOwner(r)?.personId)! } }"
|
||||
>
|
||||
<UiButton variant="ghost" size="sm">
|
||||
مشاهده شخص
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user