Compare commits
55
Commits
97f00d7e6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3696713443 | ||
|
|
d282627af7 | ||
|
|
fb6aa77465 | ||
|
|
54d360ded8 | ||
|
|
717a41e94b | ||
|
|
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 | ||
|
|
6ebd45f2cb | ||
|
|
33f33f9521 |
@@ -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
|
canceled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ActivityCompletionMode {
|
||||||
|
any
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// چه کسانی میتوانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
|
||||||
|
enum ActivityEditPolicy {
|
||||||
|
creator
|
||||||
|
assignees
|
||||||
|
}
|
||||||
|
|
||||||
enum ActivityPriority {
|
enum ActivityPriority {
|
||||||
low
|
low
|
||||||
medium
|
medium
|
||||||
@@ -151,6 +162,10 @@ model Organization {
|
|||||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||||
/// ماژولهای فعال منو برای این محیط
|
/// ماژولهای فعال منو برای این محیط
|
||||||
enabledModules String[] @default([])
|
enabledModules String[] @default([])
|
||||||
|
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
|
||||||
|
activityPriorities Json @default("{}")
|
||||||
|
/// قالبهای آمادهٔ پیامک دستی ([{ id, name, body }, …])
|
||||||
|
smsTemplates Json @default("[]")
|
||||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||||
priorArchiveEnabledAt DateTime?
|
priorArchiveEnabledAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
@@ -295,6 +310,7 @@ model User {
|
|||||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||||
ownedPeople Person[] @relation("PersonOwner")
|
ownedPeople Person[] @relation("PersonOwner")
|
||||||
ownedCompanies Company[] @relation("CompanyOwner")
|
ownedCompanies Company[] @relation("CompanyOwner")
|
||||||
|
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
|
||||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||||
activities Activity[] @relation("ActivityAssignee")
|
activities Activity[] @relation("ActivityAssignee")
|
||||||
@@ -565,6 +581,8 @@ model CustomerGroup {
|
|||||||
children CustomerGroup[] @relation("CustomerGroupTree")
|
children CustomerGroup[] @relation("CustomerGroupTree")
|
||||||
people PersonGroupLink[]
|
people PersonGroupLink[]
|
||||||
companies CompanyGroupLink[]
|
companies CompanyGroupLink[]
|
||||||
|
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود میبینند
|
||||||
|
assignees User[] @relation("CustomerGroupAssignees")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -883,6 +901,12 @@ model Activity {
|
|||||||
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک میشود
|
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک میشود
|
||||||
reminderSentAt DateTime?
|
reminderSentAt DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
/// any = با انجام یکی بسته میشود؛ all = هر مسئول باید سهم خودش را بزند
|
||||||
|
completionMode ActivityCompletionMode @default(any)
|
||||||
|
/// مسئولانی که سهم خود را در حالت all ثبت کردهاند
|
||||||
|
completedByIds String[] @default([])
|
||||||
|
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
|
||||||
|
editPolicy ActivityEditPolicy @default(creator)
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
personId String?
|
personId String?
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||||
@@ -2447,8 +2471,11 @@ model FinancialPayroll {
|
|||||||
period String
|
period String
|
||||||
baseAmount Decimal @db.Decimal(18, 2)
|
baseAmount Decimal @db.Decimal(18, 2)
|
||||||
benefits Decimal @default(0) @db.Decimal(18, 2)
|
benefits Decimal @default(0) @db.Decimal(18, 2)
|
||||||
|
benefitsReason String?
|
||||||
bonus Decimal @default(0) @db.Decimal(18, 2)
|
bonus Decimal @default(0) @db.Decimal(18, 2)
|
||||||
|
bonusReason String?
|
||||||
deductions Decimal @default(0) @db.Decimal(18, 2)
|
deductions Decimal @default(0) @db.Decimal(18, 2)
|
||||||
|
deductionsReason String?
|
||||||
payable Decimal @db.Decimal(18, 2)
|
payable Decimal @db.Decimal(18, 2)
|
||||||
dueDate DateTime
|
dueDate DateTime
|
||||||
notes String?
|
notes String?
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
ReorderActivityTypesDto,
|
ReorderActivityTypesDto,
|
||||||
UpdateActivityBoardDto,
|
UpdateActivityBoardDto,
|
||||||
UpdateActivityDto,
|
UpdateActivityDto,
|
||||||
|
UpdateActivityPrioritiesDto,
|
||||||
UpdateActivityStageDto,
|
UpdateActivityStageDto,
|
||||||
UpdateActivityTypeDto,
|
UpdateActivityTypeDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
@@ -139,6 +141,18 @@ export class ActivitiesController {
|
|||||||
return { ok: true };
|
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')
|
@Get('types')
|
||||||
listTypes(@CurrentUser() user: Actor) {
|
listTypes(@CurrentUser() user: Actor) {
|
||||||
return this.typesService.list(user.organizationId);
|
return this.typesService.list(user.organizationId);
|
||||||
@@ -196,6 +210,7 @@ export class ActivitiesController {
|
|||||||
title: query.title,
|
title: query.title,
|
||||||
customer: query.customer,
|
customer: query.customer,
|
||||||
due: query.due,
|
due: query.due,
|
||||||
|
priority: query.priority,
|
||||||
boardId: query.boardId,
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -232,6 +247,7 @@ export class ActivitiesController {
|
|||||||
title: query.title,
|
title: query.title,
|
||||||
customer: query.customer,
|
customer: query.customer,
|
||||||
due: query.due,
|
due: query.due,
|
||||||
|
priority: query.priority,
|
||||||
boardId: query.boardId,
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
query.stageId ? { stageId: query.stageId } : undefined,
|
query.stageId ? { stageId: query.stageId } : undefined,
|
||||||
@@ -265,6 +281,7 @@ export class ActivitiesController {
|
|||||||
title: query.title,
|
title: query.title,
|
||||||
customer: query.customer,
|
customer: query.customer,
|
||||||
due: query.due,
|
due: query.due,
|
||||||
|
priority: query.priority,
|
||||||
boardId: query.boardId,
|
boardId: query.boardId,
|
||||||
},
|
},
|
||||||
query.stageId ? { stageId: query.stageId } : undefined,
|
query.stageId ? { stageId: query.stageId } : undefined,
|
||||||
@@ -438,8 +455,16 @@ export class ActivitiesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/complete')
|
@Patch(':id/complete')
|
||||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
complete(
|
||||||
return this.activitiesService.complete(user.organizationId, id, user);
|
@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')
|
@Delete(':id')
|
||||||
|
|||||||
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
|
|||||||
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
'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'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||||
type StageScope = { stageId: string };
|
type StageScope = { stageId: string };
|
||||||
|
|
||||||
@@ -64,6 +123,7 @@ type ListFacets = {
|
|||||||
customer?: string;
|
customer?: string;
|
||||||
/** set = زمان شروع دارد، unset = ندارد */
|
/** set = زمان شروع دارد، unset = ندارد */
|
||||||
due?: 'set' | 'unset';
|
due?: 'set' | 'unset';
|
||||||
|
priority?: ActivityDto['priority'];
|
||||||
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
||||||
boardId?: string;
|
boardId?: string;
|
||||||
};
|
};
|
||||||
@@ -82,6 +142,9 @@ type ActivityRow = {
|
|||||||
reminderNotifySms: boolean;
|
reminderNotifySms: boolean;
|
||||||
reminderSentAt: Date | null;
|
reminderSentAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
|
completionMode?: 'any' | 'all';
|
||||||
|
completedByIds?: string[];
|
||||||
|
editPolicy?: 'creator' | 'assignees';
|
||||||
importSource?: string;
|
importSource?: string;
|
||||||
promotedFromPriorAt?: Date | null;
|
promotedFromPriorAt?: Date | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -186,22 +249,39 @@ export class ActivitiesService {
|
|||||||
return { stages, types };
|
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(
|
async summary(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
desk?: 'mine' | 'team',
|
desk?: 'mine' | 'team',
|
||||||
): Promise<ActivitySummaryDto> {
|
): Promise<ActivitySummaryDto> {
|
||||||
const { start, end } = dayBounds();
|
const { start, end } = dayBounds();
|
||||||
const scope: Prisma.ActivityWhereInput =
|
const scope = this.deskScope(actor, desk);
|
||||||
desk === 'mine'
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ assigneeId: actor.userId },
|
|
||||||
{ assignees: { some: { id: actor.userId } } },
|
|
||||||
{ participantIds: { has: actor.userId } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: activityAccessWhere(actor);
|
|
||||||
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||||
const [todayCount, overdueCount] = await Promise.all([
|
const [todayCount, overdueCount] = await Promise.all([
|
||||||
this.prisma.activity.count({
|
this.prisma.activity.count({
|
||||||
@@ -534,8 +614,24 @@ export class ActivitiesService {
|
|||||||
const stage = await this.stages.get(organizationId, stageId);
|
const stage = await this.stages.get(organizationId, stageId);
|
||||||
const existing = await this.prisma.activity.findFirst({
|
const existing = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId },
|
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({
|
const row = await this.prisma.activity.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -550,6 +646,9 @@ export class ActivitiesService {
|
|||||||
: stage.kind === 'open'
|
: stage.kind === 'open'
|
||||||
? null
|
? null
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...(stage.kind === 'open' && existing?.status !== 'open'
|
||||||
|
? { completedByIds: [] }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
@@ -675,6 +774,9 @@ export class ActivitiesService {
|
|||||||
assignees: assigneeIds.length
|
assignees: assigneeIds.length
|
||||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
|
||||||
|
completedByIds: [],
|
||||||
|
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
|
||||||
createdById: userId,
|
createdById: userId,
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
@@ -743,8 +845,31 @@ export class ActivitiesService {
|
|||||||
|
|
||||||
const existing = await this.prisma.activity.findFirst({
|
const existing = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId },
|
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 = {};
|
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||||
if ('boardId' in dto) {
|
if ('boardId' in dto) {
|
||||||
@@ -812,6 +937,7 @@ export class ActivitiesService {
|
|||||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||||
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||||
|
let nextAssigneeIds: string[] | undefined;
|
||||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||||
const current = await this.prisma.activity.findFirst({
|
const current = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
@@ -839,6 +965,7 @@ export class ActivitiesService {
|
|||||||
);
|
);
|
||||||
data.assigneeId = assigneeIds[0]!;
|
data.assigneeId = assigneeIds[0]!;
|
||||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||||
|
nextAssigneeIds = assigneeIds;
|
||||||
} else {
|
} else {
|
||||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||||
@@ -852,12 +979,44 @@ export class ActivitiesService {
|
|||||||
data.assigneeId = assigneeId;
|
data.assigneeId = assigneeId;
|
||||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||||
else assigneeConnect = [];
|
else assigneeConnect = [];
|
||||||
|
nextAssigneeIds = assigneeId ? [assigneeId] : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dto.status !== undefined) {
|
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
|
||||||
data.status = dto.status;
|
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
|
||||||
data.completedAt =
|
if (modeChanged) data.completionMode = nextMode;
|
||||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
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({
|
const row = await this.prisma.activity.update({
|
||||||
@@ -881,7 +1040,7 @@ export class ActivitiesService {
|
|||||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
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);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
return this.toDto(row, await this.ctx(organizationId));
|
return this.toDto(row, await this.ctx(organizationId));
|
||||||
@@ -891,18 +1050,74 @@ export class ActivitiesService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
|
options: { force?: boolean; undo?: boolean } = {},
|
||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
const existing = await this.prisma.activity.findFirst({
|
const existing = await this.prisma.activity.findFirst({
|
||||||
where: { id, organizationId },
|
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({
|
const row = await this.prisma.activity.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'done', completedAt: new Date() },
|
data: {
|
||||||
|
completedByIds: reconciled.completedByIds,
|
||||||
|
status: reconciled.status,
|
||||||
|
completedAt: reconciled.status === 'done' ? new Date() : null,
|
||||||
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
if (existing?.status !== 'done') {
|
if (reconciled.status === 'done' && existing.status !== 'done') {
|
||||||
await this.fireDone(organizationId, row);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
return this.toDto(row, await this.ctx(organizationId));
|
return this.toDto(row, await this.ctx(organizationId));
|
||||||
@@ -946,6 +1161,18 @@ export class ActivitiesService {
|
|||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
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({
|
const images = await this.prisma.activityImage.findMany({
|
||||||
where: { activityId: id, organizationId },
|
where: { activityId: id, organizationId },
|
||||||
select: { storageKey: true, sizeBytes: true },
|
select: { storageKey: true, sizeBytes: true },
|
||||||
@@ -966,6 +1193,19 @@ export class ActivitiesService {
|
|||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
): Promise<ActivityImageDto> {
|
): Promise<ActivityImageDto> {
|
||||||
await this.ensureExists(organizationId, activityId, actor);
|
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) {
|
if (!file?.buffer?.length) {
|
||||||
throw new BadRequestException('فایلی ارسال نشده است');
|
throw new BadRequestException('فایلی ارسال نشده است');
|
||||||
}
|
}
|
||||||
@@ -1060,10 +1300,27 @@ export class ActivitiesService {
|
|||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.ensureExists(organizationId, activityId, actor);
|
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({
|
const row = await this.prisma.activityImage.findFirst({
|
||||||
where: { id: imageId, activityId, organizationId },
|
where: { id: imageId, activityId, organizationId },
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
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.files.remove(row.storageKey);
|
||||||
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||||
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||||
@@ -1108,25 +1365,18 @@ export class ActivitiesService {
|
|||||||
typeRows: TypeRow[] = [],
|
typeRows: TypeRow[] = [],
|
||||||
): Prisma.ActivityWhereInput {
|
): Prisma.ActivityWhereInput {
|
||||||
const { start, end } = dayBounds();
|
const { start, end } = dayBounds();
|
||||||
const scope: Prisma.ActivityWhereInput =
|
const scope = this.deskScope(actor, desk);
|
||||||
desk === 'mine'
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ assigneeId: actor.userId },
|
|
||||||
{ assignees: { some: { id: actor.userId } } },
|
|
||||||
{ participantIds: { has: actor.userId } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: activityAccessWhere(actor);
|
|
||||||
const facetAssignee = facets?.assigneeId?.trim();
|
const facetAssignee = facets?.assigneeId?.trim();
|
||||||
const facetTypeDef = facets?.typeDefId?.trim();
|
const facetTypeDef = facets?.typeDefId?.trim();
|
||||||
const facetTitle = facets?.title?.trim();
|
const facetTitle = facets?.title?.trim();
|
||||||
const facetCustomer = facets?.customer?.trim();
|
const facetCustomer = facets?.customer?.trim();
|
||||||
|
const facetPriority = facets?.priority;
|
||||||
const andParts: Prisma.ActivityWhereInput[] = [
|
const andParts: Prisma.ActivityWhereInput[] = [
|
||||||
scope,
|
scope,
|
||||||
...(facets?.type ? [{ type: facets.type }] : []),
|
...(facets?.type ? [{ type: facets.type }] : []),
|
||||||
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
||||||
...(facets?.status ? [{ status: facets.status }] : []),
|
...(facets?.status ? [{ status: facets.status }] : []),
|
||||||
|
...(facetPriority ? [{ priority: facetPriority }] : []),
|
||||||
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
|
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
|
||||||
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
|
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
|
||||||
...(facetAssignee
|
...(facetAssignee
|
||||||
@@ -1418,6 +1668,9 @@ export class ActivitiesService {
|
|||||||
reminderNotifySms: row.reminderNotifySms,
|
reminderNotifySms: row.reminderNotifySms,
|
||||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||||
completedAt: row.completedAt?.toISOString() ?? null,
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
completionMode: row.completionMode ?? 'any',
|
||||||
|
completedByIds: row.completedByIds ?? [],
|
||||||
|
editPolicy: row.editPolicy ?? 'creator',
|
||||||
importSource: row.importSource ?? 'manual',
|
importSource: row.importSource ?? 'manual',
|
||||||
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
ActivityFieldValue,
|
ActivityFieldValue,
|
||||||
ActivityFieldValues,
|
ActivityFieldValues,
|
||||||
ActivityParticipantsValue,
|
ActivityParticipantsValue,
|
||||||
|
ActivityPriority,
|
||||||
|
ActivityPrioritySetting,
|
||||||
ActivityStageColor,
|
ActivityStageColor,
|
||||||
ActivityType,
|
ActivityType,
|
||||||
ActivityTypeDefDto,
|
ActivityTypeDefDto,
|
||||||
@@ -17,8 +19,11 @@ import type {
|
|||||||
import {
|
import {
|
||||||
ACTIVITY_FIELD_TYPES,
|
ACTIVITY_FIELD_TYPES,
|
||||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||||
|
ACTIVITY_PRIORITY_KEYS,
|
||||||
ACTIVITY_STAGE_COLORS,
|
ACTIVITY_STAGE_COLORS,
|
||||||
ACTIVITY_TYPE_ICONS,
|
ACTIVITY_TYPE_ICONS,
|
||||||
|
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||||
|
parseActivityPrioritySettings,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
@@ -619,6 +624,43 @@ export class ActivityTypesService {
|
|||||||
: 'green';
|
: '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) {
|
private mapError(e: unknown) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||||
@@ -91,6 +93,11 @@ export class ListActivitiesQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
typeDefId?: string;
|
typeDefId?: string;
|
||||||
|
|
||||||
|
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ACTIVITY_PRIORITIES)
|
||||||
|
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||||
|
|
||||||
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -384,6 +391,14 @@ export class CreateActivityDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
assigneeIds?: string[];
|
assigneeIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['any', 'all'])
|
||||||
|
completionMode?: 'any' | 'all';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['creator', 'assignees'])
|
||||||
|
editPolicy?: 'creator' | 'assignees';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateActivityDto {
|
export class UpdateActivityDto {
|
||||||
@@ -484,4 +499,35 @@ export class UpdateActivityDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
assigneeIds?: string[];
|
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,
|
StorageUsageDto,
|
||||||
SubscriptionSummaryDto,
|
SubscriptionSummaryDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
parseUserTreasuryAccess,
|
||||||
|
permissionsWithTreasuryOverride,
|
||||||
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||||
import type {} from 'multer';
|
import type {} from 'multer';
|
||||||
@@ -848,6 +852,9 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const role = membership?.role ?? user.role;
|
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 orgName = membership?.organization.name;
|
||||||
const sub =
|
const sub =
|
||||||
subscription ??
|
subscription ??
|
||||||
@@ -879,9 +886,10 @@ export class AuthService {
|
|||||||
storage,
|
storage,
|
||||||
isOrgOwner: role === 'owner',
|
isOrgOwner: role === 'owner',
|
||||||
priorArchive: this.priorArchiveFor(membership),
|
priorArchive: this.priorArchiveFor(membership),
|
||||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
permissions: permissionsWithTreasuryOverride(
|
||||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
rolePermissions,
|
||||||
: []) as AuthUserDto['permissions'],
|
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||||
|
) as AuthUserDto['permissions'],
|
||||||
...(viewAs ? { viewAs } : {}),
|
...(viewAs ? { viewAs } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
const peers = await this.prisma.teamMember.findMany({
|
const peers = await this.prisma.teamMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
teamId: { in: memberships.map((m) => m.teamId) },
|
teamId: { in: memberships.map((m) => m.teamId) },
|
||||||
|
team: { organizationId },
|
||||||
|
user: {
|
||||||
|
memberships: { some: { organizationId, isActive: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
defaultEnabledModules,
|
defaultEnabledModules,
|
||||||
isPermanentBillingPlan,
|
isPermanentBillingPlan,
|
||||||
isOrgModuleKey,
|
isOrgModuleKey,
|
||||||
|
modulesForProductPlan,
|
||||||
|
PRODUCT_PLAN_LIMITS,
|
||||||
|
type ProductPlanCode,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||||
@@ -414,7 +417,37 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
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() {
|
async countExpiringSoon() {
|
||||||
|
|||||||
+138
-109
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
|
|||||||
return out;
|
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(
|
export function buildContactRuleWhere(
|
||||||
rule: DataAccessRule,
|
rule: DataAccessRule,
|
||||||
ctx: { userId: string; teamPeerIds: string[] | null },
|
ctx: { userId: string; teamPeerIds: string[] | null },
|
||||||
): Prisma.PersonWhereInput | null {
|
): Prisma.PersonWhereInput | null {
|
||||||
const and: Prisma.PersonWhereInput[] = [];
|
const facets: Prisma.PersonWhereInput[] = [];
|
||||||
const ids = ownerIdList(rule, ctx);
|
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
if (!ids.length) return null;
|
const ids = ownerIdList(rule, ctx);
|
||||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
if (ids.length) {
|
||||||
}
|
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||||
if (rule.groupIds?.length) {
|
}
|
||||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
|
||||||
}
|
}
|
||||||
|
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (personViaGroupAssignee)
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (rule.moods?.length) {
|
if (rule.moods?.length) {
|
||||||
and.push({ mood: { in: rule.moods } });
|
facets.push({ mood: { in: rule.moods } });
|
||||||
}
|
}
|
||||||
if (!and.length) return null;
|
return unionFacets(facets);
|
||||||
return and.length === 1 ? and[0]! : { AND: and };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
|
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 };
|
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(
|
export function customersPersonWhere(
|
||||||
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
|
||||||
): Prisma.PersonWhereInput {
|
): Prisma.PersonWhereInput {
|
||||||
@@ -149,6 +170,8 @@ export function customersPersonWhere(
|
|||||||
});
|
});
|
||||||
if (w) parts.push(w);
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
|
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
|
||||||
|
parts.push(personViaGroupAssignee(actor.userId));
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.CompanyWhereInput[] = [];
|
const parts: Prisma.CompanyWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
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) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
|
||||||
}
|
}
|
||||||
if (rule.groupIds?.length) {
|
|
||||||
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
|
|
||||||
}
|
}
|
||||||
|
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئولگروه (companyViaGroupAssignee)
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
const w = unionFacets(facets);
|
||||||
rule.moods?.length &&
|
if (w) parts.push(w);
|
||||||
!rule.owners?.length &&
|
|
||||||
!rule.groupIds?.length &&
|
|
||||||
!rule.sources?.length
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!and.length) continue;
|
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
|
||||||
}
|
}
|
||||||
|
parts.push(companyViaGroupAssignee(actor.userId));
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.LeadWhereInput[] = [];
|
const parts: Prisma.LeadWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.LeadWhereInput[] = [];
|
const facets: Prisma.LeadWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.stageIds?.length) {
|
if (rule.stageIds?.length) {
|
||||||
and.push({ stageId: { in: rule.stageIds } });
|
facets.push({ stageId: { in: rule.stageIds } });
|
||||||
}
|
}
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: rule.sources.map((s) => ({
|
OR: rule.sources.map((s) => ({
|
||||||
source: { equals: s, mode: 'insensitive' as const },
|
source: { equals: s, mode: 'insensitive' as const },
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.DealWhereInput[] = [];
|
const parts: Prisma.DealWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.DealWhereInput[] = [];
|
const facets: Prisma.DealWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: { in: ids } },
|
{ ownerId: { in: ids } },
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.pipelineIds?.length) {
|
if (rule.pipelineIds?.length) {
|
||||||
and.push({ pipelineId: { in: rule.pipelineIds } });
|
facets.push({ pipelineId: { in: rule.pipelineIds } });
|
||||||
}
|
}
|
||||||
if (rule.stageIds?.length) {
|
if (rule.stageIds?.length) {
|
||||||
and.push({ stageId: { in: rule.stageIds } });
|
facets.push({ stageId: { in: rule.stageIds } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -266,32 +285,33 @@ export function activitiesDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.ActivityWhereInput[] = [];
|
const parts: Prisma.ActivityWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.ActivityWhereInput[] = [];
|
const facets: Prisma.ActivityWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ assigneeId: { in: ids } },
|
{ assigneeId: { in: ids } },
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
{ participantIds: { hasSome: ids } },
|
{ participantIds: { hasSome: ids } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.boardIds?.length) {
|
if (rule.boardIds?.length) {
|
||||||
and.push({ boardId: { in: rule.boardIds } });
|
facets.push({ boardId: { in: rule.boardIds } });
|
||||||
}
|
}
|
||||||
if (rule.stageIds?.length) {
|
if (rule.stageIds?.length) {
|
||||||
and.push({ stageId: { in: rule.stageIds } });
|
facets.push({ stageId: { in: rule.stageIds } });
|
||||||
}
|
}
|
||||||
if (rule.typeIds?.length) {
|
if (rule.typeIds?.length) {
|
||||||
and.push({ typeDefId: { in: rule.typeIds } });
|
facets.push({ typeDefId: { in: rule.typeIds } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -310,34 +330,35 @@ export function notesDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
if (!sectionHasDataAccessLimits(section)) return personalGate;
|
||||||
const parts: Prisma.NoteWhereInput[] = [];
|
const parts: Prisma.NoteWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.NoteWhereInput[] = [];
|
const facets: Prisma.NoteWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ authorId: { in: ids } },
|
{ authorId: { in: ids } },
|
||||||
{
|
{
|
||||||
deal: {
|
deal: {
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: { in: ids } },
|
{ ownerId: { in: ids } },
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{ person: { ownerId: { in: ids } } },
|
||||||
{ person: { ownerId: { in: ids } } },
|
{ company: { ownerId: { in: ids } } },
|
||||||
{ company: { ownerId: { in: ids } } },
|
],
|
||||||
],
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({ noteType: { in: rule.sources } });
|
facets.push({ noteType: { in: rule.sources } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
AND: [personalGate, orParts(parts)],
|
AND: [personalGate, orParts(parts)],
|
||||||
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.TicketWhereInput[] = [];
|
const parts: Prisma.TicketWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.TicketWhereInput[] = [];
|
const facets: Prisma.TicketWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
status: {
|
status: {
|
||||||
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.ProjectCardWhereInput[] = [];
|
const parts: Prisma.ProjectCardWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.ProjectCardWhereInput[] = [];
|
const facets: Prisma.ProjectCardWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push({
|
facets.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: { in: ids } },
|
{ ownerId: { in: ids } },
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.boardIds?.length) {
|
if (rule.boardIds?.length) {
|
||||||
and.push({ boardId: { in: rule.boardIds } });
|
facets.push({ boardId: { in: rule.boardIds } });
|
||||||
}
|
}
|
||||||
if (rule.stageIds?.length) {
|
if (rule.stageIds?.length) {
|
||||||
and.push({ stageId: { in: rule.stageIds } });
|
facets.push({ stageId: { in: rule.stageIds } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
|
|||||||
if (!sectionHasDataAccessLimits(section)) return {};
|
if (!sectionHasDataAccessLimits(section)) return {};
|
||||||
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
const parts: Prisma.RoutineTaskWhereInput[] = [];
|
||||||
for (const rule of section.rules ?? []) {
|
for (const rule of section.rules ?? []) {
|
||||||
const and: Prisma.RoutineTaskWhereInput[] = [];
|
const facets: Prisma.RoutineTaskWhereInput[] = [];
|
||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
const ids = ownerIdList(rule, {
|
const ids = ownerIdList(rule, {
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
teamPeerIds: actor.teamPeerIds ?? null,
|
teamPeerIds: actor.teamPeerIds ?? null,
|
||||||
});
|
});
|
||||||
if (!ids.length) continue;
|
if (ids.length) {
|
||||||
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (rule.typeIds?.length) {
|
if (rule.typeIds?.length) {
|
||||||
and.push({ categoryId: { in: rule.typeIds } });
|
facets.push({ categoryId: { in: rule.typeIds } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
if (w) parts.push(w);
|
||||||
}
|
}
|
||||||
return orParts(parts);
|
return orParts(parts);
|
||||||
}
|
}
|
||||||
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
|
|||||||
if (rule.owners?.length) {
|
if (rule.owners?.length) {
|
||||||
sawOwnerRule = true;
|
sawOwnerRule = true;
|
||||||
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
for (const id of ownerIdList(rule, ctx)) ids.add(id);
|
||||||
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
|
} else if (
|
||||||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
|
rule.sources?.length ||
|
||||||
rule.typeIds?.length) {
|
rule.moods?.length ||
|
||||||
|
rule.stageIds?.length ||
|
||||||
|
rule.pipelineIds?.length ||
|
||||||
|
rule.boardIds?.length ||
|
||||||
|
rule.typeIds?.length
|
||||||
|
) {
|
||||||
sawNonOwnerRule = true;
|
sawNonOwnerRule = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
SectionDataAccess,
|
SectionDataAccess,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} 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 type { Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
activitiesDeepWhere,
|
activitiesDeepWhere,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
notesDeepWhere,
|
notesDeepWhere,
|
||||||
projectsDeepWhere,
|
projectsDeepWhere,
|
||||||
routinesDeepWhere,
|
routinesDeepWhere,
|
||||||
|
sectionOwnerIdUnion,
|
||||||
ticketsDeepWhere,
|
ticketsDeepWhere,
|
||||||
} from './data-access';
|
} from './data-access';
|
||||||
|
|
||||||
@@ -60,6 +61,65 @@ export type Actor = {
|
|||||||
|
|
||||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
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 با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
||||||
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
||||||
const s = actor.scopes?.[scope];
|
const s = actor.scopes?.[scope];
|
||||||
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
|
|||||||
|
|
||||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||||
export function ownerWhere(
|
export function ownerWhere(
|
||||||
actor: ScopedActor,
|
actor: DeepAccessActor,
|
||||||
scope: DataScope = 'contacts',
|
scope: DataScope = 'contacts',
|
||||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
): { 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);
|
const ids = scopedOwnerIds(actor, scope);
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||||
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||||
export function assigneeWhere(
|
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
|
||||||
actor: ScopedActor,
|
const deep = deepOwnerIds(actor, 'activities');
|
||||||
): Prisma.ActivityWhereInput {
|
const ids =
|
||||||
const ids = scopedOwnerIds(actor, 'activities');
|
deep.kind === 'open'
|
||||||
|
? null
|
||||||
|
: deep.kind === 'ids'
|
||||||
|
? deep.ids
|
||||||
|
: scopedOwnerIds(actor, 'activities');
|
||||||
if (!ids) return {};
|
if (!ids) return {};
|
||||||
|
if (!ids.length) return { assigneeId: '__no_access__' };
|
||||||
return {
|
return {
|
||||||
OR: [
|
OR: [
|
||||||
{ assigneeId: { in: ids } },
|
{ assigneeId: { in: ids } },
|
||||||
|
|||||||
@@ -21,15 +21,82 @@ function tehranJalaliLong(d: Date) {
|
|||||||
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** قالب بس/بد مثل UI تهحساب — برای توکنهای مانده در پیامک */
|
||||||
|
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
|
||||||
|
if (v == null) return '';
|
||||||
|
const s = String(v).trim();
|
||||||
|
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
|
||||||
|
const n = Number(s.replace(/,/g, ''));
|
||||||
|
if (!Number.isFinite(n)) return toFaDigits(s);
|
||||||
|
const abs = toFaDigits(
|
||||||
|
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
|
||||||
|
);
|
||||||
|
if (n === 0) return abs;
|
||||||
|
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METAL_FIELD_LABELS: Record<string, string> = {
|
||||||
|
MandeyeVazni: 'طلا',
|
||||||
|
MandeyeVazniNoghre: 'نقره',
|
||||||
|
MandeyeKolBeTALA: 'کل به طلا',
|
||||||
|
MandeMenhayeSeke: 'منهای سکه',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMetalMandehKey(key: string) {
|
||||||
|
if (METAL_FIELD_LABELS[key]) return true;
|
||||||
|
if (key.startsWith('Seke_')) return true;
|
||||||
|
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metalFieldLabel(key: string) {
|
||||||
|
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
|
||||||
|
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** از فیلدهای مانده تهحساب، متن مانده ریالی و فلزی میسازد */
|
||||||
|
export function smsBalanceTexts(input: {
|
||||||
|
mandehFields?: Array<{ key: string; value: string | null }> | null;
|
||||||
|
rialBalance?: number | null;
|
||||||
|
goldBalance?: number | null;
|
||||||
|
}): { rialBalance: string; metalBalance: string } {
|
||||||
|
const fields = input.mandehFields ?? [];
|
||||||
|
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
|
||||||
|
const rial =
|
||||||
|
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
|
||||||
|
formatSmsBedBes(input.rialBalance);
|
||||||
|
const metalParts = fields
|
||||||
|
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
|
||||||
|
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const metal =
|
||||||
|
metalParts.join('، ') ||
|
||||||
|
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
|
||||||
|
? `طلا ${formatSmsBedBes(input.goldBalance)}`
|
||||||
|
: '');
|
||||||
|
return { rialBalance: rial, metalBalance: metal };
|
||||||
|
}
|
||||||
|
|
||||||
export function renderSmsText(
|
export function renderSmsText(
|
||||||
text: string,
|
text: string,
|
||||||
vars: { name?: string; phone?: string; title?: string; company?: string },
|
vars: {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
title?: string;
|
||||||
|
company?: string;
|
||||||
|
from?: string;
|
||||||
|
rialBalance?: string;
|
||||||
|
metalBalance?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return applyMessageTemplate(text, {
|
return applyMessageTemplate(text, {
|
||||||
name: vars.name,
|
name: vars.name,
|
||||||
phone: vars.phone,
|
phone: vars.phone,
|
||||||
title: vars.title,
|
title: vars.title,
|
||||||
company: vars.company,
|
company: vars.company,
|
||||||
|
from: vars.from,
|
||||||
|
rialBalance: vars.rialBalance,
|
||||||
|
metalBalance: vars.metalBalance,
|
||||||
date: tehranJalaliLong(new Date()),
|
date: tehranJalaliLong(new Date()),
|
||||||
}).trim();
|
}).trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
|
||||||
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
|
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
|
||||||
return {
|
return {
|
||||||
|
year: today.jy,
|
||||||
month: today.jm,
|
month: today.jm,
|
||||||
monthName: jalaliMonthName(today.jm),
|
monthName: jalaliMonthName(today.jm),
|
||||||
today: today.jd,
|
today: today.jd,
|
||||||
|
|||||||
@@ -112,9 +112,13 @@ export class CompaniesService {
|
|||||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||||
];
|
];
|
||||||
const where: Prisma.CompanyWhereInput = {
|
const where: Prisma.CompanyWhereInput = {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...customersCompanyWhere(actor),
|
{
|
||||||
...(and.length ? { AND: and } : {}),
|
organizationId: actor.organizationId,
|
||||||
|
...customersCompanyWhere(actor),
|
||||||
|
},
|
||||||
|
...and,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rows, total] = await this.prisma.$transaction([
|
const [rows, total] = await this.prisma.$transaction([
|
||||||
@@ -165,6 +169,7 @@ export class CompaniesService {
|
|||||||
fromTahesab: Boolean(g.tahesabGid),
|
fromTahesab: Boolean(g.tahesabGid),
|
||||||
personCount: 0,
|
personCount: 0,
|
||||||
companyCount: countMap.get(g.id) ?? 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 { AutomationsModule } from '../automations/automations.module';
|
||||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||||
import { PublicApiModule } from '../public-api/public-api.module';
|
import { PublicApiModule } from '../public-api/public-api.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from './companies.controller';
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from './companies.service';
|
||||||
import { PeopleController } from './people.controller';
|
import { PeopleController } from './people.controller';
|
||||||
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
|||||||
PublicApiModule,
|
PublicApiModule,
|
||||||
BehaviorModule,
|
BehaviorModule,
|
||||||
CustomerGroupsModule,
|
CustomerGroupsModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [PeopleController, CompaniesController],
|
controllers: [PeopleController, CompaniesController],
|
||||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
text!: string;
|
text!: string;
|
||||||
|
|
||||||
|
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BulkDeletePeopleDto {
|
export class BulkDeletePeopleDto {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export class PeopleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('birthdays')
|
@Get('birthdays')
|
||||||
|
@RequirePermission('customers', { reads: true })
|
||||||
birthdays(@CurrentUser() user: Actor) {
|
birthdays(@CurrentUser() user: Actor) {
|
||||||
return this.peopleService.birthdaysThisMonth(user);
|
return this.peopleService.birthdaysThisMonth(user);
|
||||||
}
|
}
|
||||||
@@ -171,7 +172,7 @@ export class PeopleController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: BirthdaySmsDto,
|
@Body() dto: BirthdaySmsDto,
|
||||||
) {
|
) {
|
||||||
return this.peopleService.sendBirthdaySms(user, id, dto.text);
|
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
FILTERED_IDS_MAX,
|
FILTERED_IDS_MAX,
|
||||||
PERSON_MOODS,
|
PERSON_MOODS,
|
||||||
applyPersonGenderTitle,
|
applyPersonGenderTitle,
|
||||||
|
canViewAllRecords,
|
||||||
splitPersonHonorific,
|
splitPersonHonorific,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -48,13 +49,15 @@ import {
|
|||||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { AutomationsService } from '../automations/automations.service';
|
import { AutomationsService } from '../automations/automations.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||||
import { WebhooksService } from '../public-api/webhooks.service';
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { parseBirthDate } from '../common/excel';
|
import { parseBirthDate } from '../common/excel';
|
||||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { selectMonthBirthdays } from './birthday-month';
|
import { selectMonthBirthdays } from './birthday-month';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText, smsBalanceTexts } from '../common/sms-text';
|
||||||
|
import { extractMandehRaw } from '../integrations/tahesab/tahesab-methods';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
import { buildSearchWhere, ci, splitCsv } from '../common/search';
|
||||||
import {
|
import {
|
||||||
@@ -157,6 +160,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
private readonly behaviorAccess: BehaviorAccessService,
|
private readonly behaviorAccess: BehaviorAccessService,
|
||||||
private readonly groups: CustomerGroupsService,
|
private readonly groups: CustomerGroupsService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private personListWhere(
|
private personListWhere(
|
||||||
@@ -216,13 +220,27 @@ export class PeopleService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const disc = discFilter(opts?.disc);
|
const disc = discFilter(opts?.disc);
|
||||||
if (disc) and.push(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 {
|
return {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...customersPersonWhere(actor),
|
{
|
||||||
...(moods.length ? { mood: { in: moods } } : {}),
|
organizationId: actor.organizationId,
|
||||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
...customersPersonWhere(actor),
|
||||||
...(and.length ? { AND: and } : {}),
|
},
|
||||||
|
...and,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +363,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
fromTahesab: Boolean(g.tahesabGid),
|
fromTahesab: Boolean(g.tahesabGid),
|
||||||
personCount: countMap.get(g.id) ?? 0,
|
personCount: countMap.get(g.id) ?? 0,
|
||||||
companyCount: 0,
|
companyCount: 0,
|
||||||
|
assignees: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,8 +699,8 @@ export class PeopleService implements OnModuleInit {
|
|||||||
const rows = await this.prisma.person.findMany({
|
const rows = await this.prisma.person.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
ownerId: actor.userId,
|
|
||||||
birthDate: { not: null },
|
birthDate: { not: null },
|
||||||
|
...customersPersonWhere(actor),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -728,27 +747,50 @@ 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();
|
const body = text.trim();
|
||||||
if (!body || body.length > 500) {
|
if (!body || body.length > 500) {
|
||||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||||
}
|
}
|
||||||
const person = await this.prisma.person.findFirst({
|
const person = await this.prisma.person.findFirst({
|
||||||
where: { id: personId, organizationId: actor.organizationId },
|
where: {
|
||||||
|
id: personId,
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...customersPersonWhere(actor),
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
mobile: true,
|
mobile: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
|
position: true,
|
||||||
ownerId: true,
|
ownerId: true,
|
||||||
birthDate: true,
|
birthDate: true,
|
||||||
company: { select: { name: true } },
|
company: { select: { name: true } },
|
||||||
|
groupLinks: {
|
||||||
|
select: {
|
||||||
|
group: {
|
||||||
|
select: {
|
||||||
|
assignees: { select: { id: true, fullName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
if (!person) throw new NotFoundException('مشتری پیدا نشد');
|
||||||
if (person.ownerId !== actor.userId) {
|
const assigneeIds = new Set(
|
||||||
throw new ForbiddenException('فقط مسئول این مشتری میتواند پیامک تبریک بفرستد.');
|
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) {
|
if (!person.birthDate) {
|
||||||
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
|
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
|
||||||
@@ -770,12 +812,110 @@ export class PeopleService implements OnModuleInit {
|
|||||||
const mobile = person.mobile || person.phone;
|
const mobile = person.mobile || person.phone;
|
||||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||||
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
const fullName = `${person.firstName} ${person.lastName}`.trim();
|
||||||
const message = renderSmsText(body, {
|
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
|
||||||
name: fullName,
|
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,
|
||||||
|
await this.personSmsVars(actor.organizationId, person.id, {
|
||||||
|
name: fullName,
|
||||||
|
phone: mobile,
|
||||||
|
title: person.position?.trim() || '',
|
||||||
|
company: person.company?.name ?? '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
phone: mobile,
|
phone: mobile,
|
||||||
title: fullName,
|
text: message,
|
||||||
company: person.company?.name ?? '',
|
source: 'manual',
|
||||||
});
|
});
|
||||||
|
await this.outboundSms.record({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
personId: person.id,
|
||||||
|
mobile,
|
||||||
|
body: message,
|
||||||
|
source: 'manual',
|
||||||
|
sourceId: birthdayKey,
|
||||||
|
status: result.ok ? 'sent' : 'failed',
|
||||||
|
error: result.ok ? null : result.reason,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
result.blocked
|
||||||
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSms(actor: Actor, personId: string, text: string) {
|
||||||
|
const body = text.trim();
|
||||||
|
if (!body || body.length > 500) {
|
||||||
|
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
||||||
|
}
|
||||||
|
const person = await this.get(actor, personId);
|
||||||
|
const mobile = person.mobile || person.phone;
|
||||||
|
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
||||||
|
const message = renderSmsText(
|
||||||
|
body,
|
||||||
|
await this.personSmsVars(actor.organizationId, person.id, {
|
||||||
|
name: person.fullName,
|
||||||
|
phone: mobile,
|
||||||
|
title: person.position?.trim() || '',
|
||||||
|
company: person.company?.name ?? '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.send({
|
const result = await this.sms.send({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
@@ -805,47 +945,50 @@ export class PeopleService implements OnModuleInit {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSms(actor: Actor, personId: string, text: string) {
|
/** نام سازمان + ماندههای کششدهٔ تهحساب برای توکنهای پیامک */
|
||||||
const body = text.trim();
|
private async personSmsVars(
|
||||||
if (!body || body.length > 500) {
|
organizationId: string,
|
||||||
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
|
personId: string,
|
||||||
|
base: {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
title: string;
|
||||||
|
company: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const [org, link] = await Promise.all([
|
||||||
|
this.prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
this.prisma.personAccountingLink.findFirst({
|
||||||
|
where: { personId, connection: { organizationId } },
|
||||||
|
orderBy: [
|
||||||
|
{ connection: { isDefault: 'desc' } },
|
||||||
|
{ linkedAt: 'desc' },
|
||||||
|
],
|
||||||
|
include: { balanceCache: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let rialBalance = '';
|
||||||
|
let metalBalance = '';
|
||||||
|
const cache = link?.balanceCache;
|
||||||
|
if (cache) {
|
||||||
|
const rawView = extractMandehRaw(cache.rawResponse);
|
||||||
|
const bal = smsBalanceTexts({
|
||||||
|
mandehFields: rawView.fields,
|
||||||
|
rialBalance: cache.rialBalance,
|
||||||
|
goldBalance: cache.goldBalance,
|
||||||
|
});
|
||||||
|
rialBalance = bal.rialBalance;
|
||||||
|
metalBalance = bal.metalBalance;
|
||||||
}
|
}
|
||||||
const person = await this.get(actor, personId);
|
return {
|
||||||
const mobile = person.mobile || person.phone;
|
...base,
|
||||||
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
|
from: org?.name?.trim() || '',
|
||||||
const message = renderSmsText(body, {
|
rialBalance,
|
||||||
name: person.fullName,
|
metalBalance,
|
||||||
phone: mobile,
|
};
|
||||||
title: person.fullName,
|
|
||||||
company: person.company?.name ?? '',
|
|
||||||
});
|
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
|
||||||
const result = await this.sms.send({
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
phone: mobile,
|
|
||||||
text: message,
|
|
||||||
source: 'manual',
|
|
||||||
});
|
|
||||||
await this.outboundSms.record({
|
|
||||||
organizationId: actor.organizationId,
|
|
||||||
personId: person.id,
|
|
||||||
mobile,
|
|
||||||
body: message,
|
|
||||||
source: 'manual',
|
|
||||||
status: result.ok ? 'sent' : 'failed',
|
|
||||||
error: result.ok ? null : result.reason,
|
|
||||||
actorUserId: actor.userId,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
result.blocked
|
|
||||||
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
|
||||||
: result.reason === 'SMS not configured'
|
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listSmsHistory(actor: Actor, personId: string, limit?: number) {
|
listSmsHistory(actor: Actor, personId: string, limit?: number) {
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/roles.guard';
|
import { RolesGuard } from '../auth/roles.guard';
|
||||||
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CUSTOMER_GROUP_COLORS])
|
@IsIn([...CUSTOMER_GROUP_COLORS])
|
||||||
color?: CustomerGroupColor;
|
color?: CustomerGroupColor;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateCustomerGroupDto {
|
class UpdateCustomerGroupDto {
|
||||||
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
assigneeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('customer-groups')
|
@Controller('customer-groups')
|
||||||
|
|||||||
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
|
|||||||
include: {
|
include: {
|
||||||
_count: { select: { people: true, companies: true } },
|
_count: { select: { people: true, companies: true } },
|
||||||
parent: { select: { name: true } },
|
parent: { select: { name: true } },
|
||||||
|
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => this.toDto(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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: { name: string; color?: CustomerGroupColor },
|
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
|
||||||
): Promise<CustomerGroupDto> {
|
): Promise<CustomerGroupDto> {
|
||||||
const name = this.cleanName(dto.name);
|
const name = this.cleanName(dto.name);
|
||||||
|
const assigneeIds =
|
||||||
|
dto.assigneeIds !== undefined
|
||||||
|
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||||
|
: [];
|
||||||
const max = await this.prisma.customerGroup.aggregate({
|
const max = await this.prisma.customerGroup.aggregate({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
_max: { position: true },
|
_max: { position: true },
|
||||||
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
|
|||||||
name,
|
name,
|
||||||
color: this.cleanColor(dto.color),
|
color: this.cleanColor(dto.color),
|
||||||
position: (max._max.position ?? -1) + 1,
|
position: (max._max.position ?? -1) + 1,
|
||||||
|
...(assigneeIds.length
|
||||||
|
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
|
|||||||
async update(
|
async update(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
|
dto: {
|
||||||
|
name?: string;
|
||||||
|
color?: CustomerGroupColor;
|
||||||
|
parentId?: string | null;
|
||||||
|
assigneeIds?: string[];
|
||||||
|
},
|
||||||
): Promise<CustomerGroupDto> {
|
): Promise<CustomerGroupDto> {
|
||||||
await this.require(organizationId, id);
|
await this.require(organizationId, id);
|
||||||
const parentId =
|
const parentId =
|
||||||
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
|
|||||||
if (parentId !== undefined) {
|
if (parentId !== undefined) {
|
||||||
await this.assertParent(organizationId, id, parentId);
|
await this.assertParent(organizationId, id, parentId);
|
||||||
}
|
}
|
||||||
|
const assigneeIds =
|
||||||
|
dto.assigneeIds !== undefined
|
||||||
|
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
await this.prisma.customerGroup.update({
|
await this.prisma.customerGroup.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
|
|||||||
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
|
||||||
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
|
||||||
...(parentId !== undefined ? { parentId } : {}),
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
...(assigneeIds !== undefined
|
||||||
|
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
|
|||||||
return link?.group.name ?? null;
|
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) {
|
private async require(organizationId: string, id: string) {
|
||||||
const row = await this.prisma.customerGroup.findFirst({
|
const row = await this.prisma.customerGroup.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||||
|
import {
|
||||||
|
pickAddress,
|
||||||
|
pickCity,
|
||||||
|
pickNationalId,
|
||||||
|
pickRasteh,
|
||||||
|
tahesabProfilePatch,
|
||||||
|
} from '../moshtari-profile';
|
||||||
import {
|
import {
|
||||||
assertFactorCode,
|
assertFactorCode,
|
||||||
buildFactorCode,
|
buildFactorCode,
|
||||||
@@ -116,12 +123,52 @@ function testSanadBuilders() {
|
|||||||
assert(bank[12] === fc, 'bank factor index');
|
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() {
|
function main() {
|
||||||
testCryptoRoundtrip();
|
testCryptoRoundtrip();
|
||||||
testFactorCode();
|
testFactorCode();
|
||||||
testMobile();
|
testMobile();
|
||||||
testMandehNormalize();
|
testMandehNormalize();
|
||||||
testSanadBuilders();
|
testSanadBuilders();
|
||||||
|
testMoshtariProfile();
|
||||||
console.log('tahesab unit checks: OK');
|
console.log('tahesab unit checks: OK');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** اجرای دستی همگامسازی همهٔ مشتریان (وارد + بهروز کردن لینکشدهها) */
|
||||||
|
export class SyncAllCustomersDto {
|
||||||
|
@IsString()
|
||||||
|
connectionId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ExportPeopleToTahesabDto {
|
export class ExportPeopleToTahesabDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
connectionId!: string;
|
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,
|
SearchTahesabDto,
|
||||||
SyncGroupsPageDto,
|
SyncGroupsPageDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
import { formatJalaliDate } from '../../common/excel';
|
||||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||||
|
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
|
||||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||||
|
|
||||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||||
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
|
|||||||
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
|
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 {
|
function firstAddress(raw: unknown): string | undefined {
|
||||||
if (!Array.isArray(raw)) return undefined;
|
if (!Array.isArray(raw)) return undefined;
|
||||||
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
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) {
|
async search(organizationId: string, dto: SearchTahesabDto) {
|
||||||
const { api } = await this.connections.getApi(
|
const { api } = await this.connections.getApi(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
|
|||||||
});
|
});
|
||||||
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
|
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;
|
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
|
|||||||
: null,
|
: 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({
|
await this.prisma.accountingSyncJob.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
|
|||||||
const sourceRef = String(dto.moshtariCode);
|
const sourceRef = String(dto.moshtariCode);
|
||||||
|
|
||||||
let personId = dto.personId;
|
let personId = dto.personId;
|
||||||
let preserveExistingLink = false;
|
|
||||||
if (personId) {
|
if (personId) {
|
||||||
const existing = await this.getPerson(organizationId, personId);
|
const existing = await this.getPerson(organizationId, personId);
|
||||||
const prevFirst = existing.firstName;
|
const prevFirst = existing.firstName;
|
||||||
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
|
|||||||
...carryPreviousSource(existing, 'tahesab'),
|
...carryPreviousSource(existing, 'tahesab'),
|
||||||
importSource: 'tahesab',
|
importSource: 'tahesab',
|
||||||
sourceRef,
|
sourceRef,
|
||||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
|
||||||
...canonicalMobileUpdate(mobile, existing),
|
...canonicalMobileUpdate(mobile, existing),
|
||||||
|
...tahesabProfilePatch(row, existing),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمیخورند
|
||||||
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
// تا کد قبلی جابهجا نشود و این کد هم مشتری تکراری نسازد.
|
||||||
const mobileKeys = phoneIdentityKeys(mobile);
|
const mobileKeys = phoneIdentityKeys(mobile);
|
||||||
const matches = mobileKeys.length
|
const matches = mobileKeys.length
|
||||||
? await this.prisma.person.findMany({
|
? await this.prisma.person.findMany({
|
||||||
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
|
|||||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||||
if (byMobile) {
|
if (byMobile) {
|
||||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
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 =
|
const nameChanged =
|
||||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||||
`${firstName} ${lastName}`.trim();
|
`${firstName} ${lastName}`.trim();
|
||||||
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
|
|||||||
: {}),
|
: {}),
|
||||||
...carryPreviousSource(byMobile, 'tahesab'),
|
...carryPreviousSource(byMobile, 'tahesab'),
|
||||||
importSource: 'tahesab',
|
importSource: 'tahesab',
|
||||||
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
sourceRef,
|
||||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
|
||||||
...canonicalMobileUpdate(mobile, byMobile),
|
...canonicalMobileUpdate(mobile, byMobile),
|
||||||
|
...tahesabProfilePatch(row, byMobile),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
personId = byMobile.id;
|
personId = byMobile.id;
|
||||||
} else {
|
} else {
|
||||||
|
const profile = tahesabProfilePatch(row, {
|
||||||
|
birthDate: null,
|
||||||
|
addresses: [],
|
||||||
|
customFields: {},
|
||||||
|
});
|
||||||
const created = await this.prisma.person.create({
|
const created = await this.prisma.person.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
|
|||||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||||
importSource: 'tahesab',
|
importSource: 'tahesab',
|
||||||
sourceRef,
|
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;
|
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({
|
const link = await this.prisma.personAccountingLink.upsert({
|
||||||
where: {
|
where: {
|
||||||
personId_connectionId: {
|
personId_connectionId: {
|
||||||
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
|
|||||||
return { personId, link: this.linkDto(link) };
|
return { personId, link: this.linkDto(link) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
/** پروفایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||||
private async refreshLinkedCustomer(
|
private async refreshLinkedCustomer(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
|
|||||||
...carryPreviousSource(existing, 'tahesab'),
|
...carryPreviousSource(existing, 'tahesab'),
|
||||||
importSource: 'tahesab',
|
importSource: 'tahesab',
|
||||||
sourceRef: String(moshtariCode),
|
sourceRef: String(moshtariCode),
|
||||||
...(!existing.birthDate && pickBirthDate(row)
|
|
||||||
? { birthDate: pickBirthDate(row) }
|
|
||||||
: {}),
|
|
||||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||||
|
...tahesabProfilePatch(row, existing),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||||
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
|
|||||||
let emptyStreak = 0;
|
let emptyStreak = 0;
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
let duplicate = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const maxPages = 300;
|
const maxPages = 300;
|
||||||
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
|
|||||||
);
|
);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
await this.importFromTahesab(
|
const created = await this.importFromTahesab(
|
||||||
organizationId,
|
organizationId,
|
||||||
{ connectionId, moshtariCode: code },
|
{ connectionId, moshtariCode: code },
|
||||||
{ api, row },
|
{ api, row },
|
||||||
{ skipJob: true },
|
{ skipJob: true },
|
||||||
);
|
);
|
||||||
imported += 1;
|
if (created.duplicateOf) duplicate += 1;
|
||||||
|
else imported += 1;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
|
|||||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
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({
|
await this.prisma.accountingSyncJob.create({
|
||||||
data: {
|
data: {
|
||||||
organizationId,
|
organizationId,
|
||||||
connectionId,
|
connectionId,
|
||||||
kind: 'customer-sync',
|
kind: 'customer-sync',
|
||||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
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}`);
|
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||||
return {
|
return {
|
||||||
imported,
|
imported,
|
||||||
updated,
|
updated,
|
||||||
|
duplicate,
|
||||||
failed,
|
failed,
|
||||||
summary,
|
summary,
|
||||||
error: errors.length ? errors.join('؛ ') : null,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
|
|||||||
try {
|
try {
|
||||||
const linkedPersonId = linkedByCode.get(moshtariCode);
|
const linkedPersonId = linkedByCode.get(moshtariCode);
|
||||||
if (linkedPersonId) {
|
if (linkedPersonId) {
|
||||||
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
|
const row = rowsByCode.get(moshtariCode);
|
||||||
if (birthDate) {
|
if (row) {
|
||||||
await this.prisma.person.updateMany({
|
await this.refreshLinkedCustomer(
|
||||||
where: { id: linkedPersonId, birthDate: null },
|
organizationId,
|
||||||
data: { birthDate },
|
dto.connectionId,
|
||||||
});
|
linkedPersonId,
|
||||||
|
moshtariCode,
|
||||||
|
row,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.applyTahesabGroup(
|
|
||||||
organizationId,
|
|
||||||
dto.connectionId,
|
|
||||||
linkedPersonId,
|
|
||||||
rowsByCode.get(moshtariCode),
|
|
||||||
);
|
|
||||||
items.push({
|
items.push({
|
||||||
moshtariCode,
|
moshtariCode,
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
|
|||||||
{ connectionId: dto.connectionId, moshtariCode },
|
{ connectionId: dto.connectionId, moshtariCode },
|
||||||
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
|
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 });
|
items.push({ moshtariCode, ok: true, personId: r.personId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
SearchTahesabDto,
|
SearchTahesabDto,
|
||||||
SearchTahesabProductsDto,
|
SearchTahesabProductsDto,
|
||||||
SyncAsnadReportDto,
|
SyncAsnadReportDto,
|
||||||
|
SyncAllCustomersDto,
|
||||||
SyncGroupsPageDto,
|
SyncGroupsPageDto,
|
||||||
UpdateTahesabConnectionDto,
|
UpdateTahesabConnectionDto,
|
||||||
UpdateTahesabInvoiceSettingsDto,
|
UpdateTahesabInvoiceSettingsDto,
|
||||||
@@ -237,6 +238,16 @@ export class TahesabController {
|
|||||||
return this.links.syncGroupsPage(user.organizationId, dto);
|
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')
|
@Get('sync/unlinked-people')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
unlinkedPeople(
|
unlinkedPeople(
|
||||||
|
|||||||
@@ -176,22 +176,26 @@ export class LeadsService {
|
|||||||
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
|
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
|
||||||
if (createdRange) and.push(createdRange);
|
if (createdRange) and.push(createdRange);
|
||||||
return {
|
return {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...leadAccessWhere(actor),
|
{
|
||||||
archivedAt: opts.archived ? { not: null } : null,
|
organizationId: actor.organizationId,
|
||||||
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
...leadAccessWhere(actor),
|
||||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
archivedAt: opts.archived ? { not: null } : null,
|
||||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||||
...(opts.region
|
...(opts.region
|
||||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||||
: {}),
|
: {}),
|
||||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||||
...(opts.source
|
...(opts.source
|
||||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||||
: {}),
|
: {}),
|
||||||
...(and.length ? { AND: and } : {}),
|
},
|
||||||
|
...(opts.unassigned ? [{ assigneeId: null }] : []),
|
||||||
|
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
|
||||||
|
...and,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,7 +1189,7 @@ export class LeadsService {
|
|||||||
newStage
|
newStage
|
||||||
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
|
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
|
||||||
convertedIds.length
|
convertedIds.length
|
||||||
? this.prisma.lead.count({
|
? this.prisma.lead.count({
|
||||||
where: { ...base, stageId: { in: convertedIds } },
|
where: { ...base, stageId: { in: convertedIds } },
|
||||||
@@ -1568,11 +1572,16 @@ export class LeadsService {
|
|||||||
const mobile = lead.mobile || lead.phone;
|
const mobile = lead.mobile || lead.phone;
|
||||||
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
|
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
|
||||||
const name = (lead.contactName || lead.title).trim();
|
const name = (lead.contactName || lead.title).trim();
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: actor.organizationId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
const message = renderSmsText(body, {
|
const message = renderSmsText(body, {
|
||||||
name,
|
name,
|
||||||
phone: mobile,
|
phone: mobile,
|
||||||
title: lead.title,
|
title: lead.title,
|
||||||
company: lead.contactName ? lead.title : '',
|
company: lead.trade?.trim() || '',
|
||||||
|
from: org?.name?.trim() || '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.send({
|
const result = await this.sms.send({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
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: {
|
async createMembership(data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -78,6 +89,9 @@ export class MembershipsService {
|
|||||||
commissionPercent?: number;
|
commissionPercent?: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (data.role === 'owner' && data.isActive !== false) {
|
||||||
|
await this.assertSoleOwner(data.organizationId, data.userId);
|
||||||
|
}
|
||||||
return this.prisma.membership.create({
|
return this.prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
|
|||||||
@@ -56,7 +56,19 @@ function testTemplate() {
|
|||||||
}) === 'سلام علی 0912',
|
}) === 'سلام علی 0912',
|
||||||
'name phone vars',
|
'name phone vars',
|
||||||
);
|
);
|
||||||
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
|
assert(
|
||||||
|
applyMessageTemplate('تبریک {از طرف}', { from: 'هنراندیشه' }) ===
|
||||||
|
'تبریک هنراندیشه',
|
||||||
|
'from org var',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
applyMessageTemplate('مانده {مانده ریالی} / {مانده فلزی}', {
|
||||||
|
rialBalance: '۱۰۰۰ بس',
|
||||||
|
metalBalance: 'طلا ۲',
|
||||||
|
}) === 'مانده ۱۰۰۰ بس / طلا ۲',
|
||||||
|
'balance vars',
|
||||||
|
);
|
||||||
|
assert(clipSms('ا'.repeat(200)).length === 200, 'sms is not clipped');
|
||||||
}
|
}
|
||||||
|
|
||||||
testDelay();
|
testDelay();
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ export function buildSendAts(
|
|||||||
return times;
|
return times;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یکسانسازی کلید توکنهای قالب (فاصله/ZWNJ/ی عربی). */
|
||||||
|
export function normalizeTemplateKey(key: string): string {
|
||||||
|
return String(key ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
|
||||||
|
.replace(/ي/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function applyMessageTemplate(
|
export function applyMessageTemplate(
|
||||||
text: string,
|
text: string,
|
||||||
vars: {
|
vars: {
|
||||||
@@ -85,22 +96,44 @@ export function applyMessageTemplate(
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
|
/** نام سازمان فرستنده */
|
||||||
|
from?: string;
|
||||||
|
/** مانده مالی قالببندیشده */
|
||||||
|
rialBalance?: string;
|
||||||
|
/** ماندههای وزنی/فلزی قالببندیشده */
|
||||||
|
metalBalance?: string;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const map: Record<string, string> = {
|
const from = vars.from ?? '';
|
||||||
title: vars.title ?? '',
|
const rial = vars.rialBalance ?? '';
|
||||||
عنوان: vars.title ?? '',
|
const metal = vars.metalBalance ?? '';
|
||||||
نام: vars.name ?? '',
|
const entries: Array<[string, string]> = [
|
||||||
name: vars.name ?? '',
|
['title', vars.title ?? ''],
|
||||||
شماره: vars.phone ?? '',
|
['عنوان', vars.title ?? ''],
|
||||||
phone: vars.phone ?? '',
|
['نام', vars.name ?? ''],
|
||||||
تاریخ: vars.date ?? '',
|
['name', vars.name ?? ''],
|
||||||
date: vars.date ?? '',
|
['شماره', vars.phone ?? ''],
|
||||||
شرکت: vars.company ?? '',
|
['phone', vars.phone ?? ''],
|
||||||
company: vars.company ?? '',
|
['تاریخ', vars.date ?? ''],
|
||||||
};
|
['date', vars.date ?? ''],
|
||||||
|
['شرکت', vars.company ?? ''],
|
||||||
|
['company', vars.company ?? ''],
|
||||||
|
['از طرف', from],
|
||||||
|
['ازطرف', from],
|
||||||
|
['from', from],
|
||||||
|
['مانده ریالی', rial],
|
||||||
|
['ماندهریالی', rial],
|
||||||
|
['rialBalance', rial],
|
||||||
|
['مانده فلزی', metal],
|
||||||
|
['ماندهفلزی', metal],
|
||||||
|
['metalBalance', metal],
|
||||||
|
];
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
map[normalizeTemplateKey(key)] = value;
|
||||||
|
}
|
||||||
return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
|
return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
|
||||||
const next = map[key.trim()];
|
const next = map[normalizeTemplateKey(key)];
|
||||||
return next === undefined ? full : next;
|
return next === undefined ? full : next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,6 +356,10 @@ export class MessageAutomationsService {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const lead = await this.lookupLead(job);
|
const lead = await this.lookupLead(job);
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: job.organizationId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
const personName = person
|
const personName = person
|
||||||
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
|
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
|
||||||
: '';
|
: '';
|
||||||
@@ -366,6 +370,7 @@ export class MessageAutomationsService {
|
|||||||
phone,
|
phone,
|
||||||
date: formatJalaliDayFa(new Date()),
|
date: formatJalaliDayFa(new Date()),
|
||||||
company: person?.company?.name ?? '',
|
company: person?.company?.name ?? '',
|
||||||
|
from: org?.name?.trim() || '',
|
||||||
};
|
};
|
||||||
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
|
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
|
||||||
const body = applyMessageTemplate(job.rule.body, vars);
|
const body = applyMessageTemplate(job.rule.body, vars);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { OutboundSmsLogService } from './outbound-sms-log.service';
|
import { OutboundSmsLogService } from './outbound-sms-log.service';
|
||||||
|
import { SmsTemplatesController } from './sms-templates.controller';
|
||||||
|
import { SmsTemplatesService } from './sms-templates.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [OutboundSmsLogService],
|
controllers: [SmsTemplatesController],
|
||||||
exports: [OutboundSmsLogService],
|
providers: [OutboundSmsLogService, SmsTemplatesService],
|
||||||
|
exports: [OutboundSmsLogService, SmsTemplatesService],
|
||||||
})
|
})
|
||||||
export class OutboundSmsModule {}
|
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) {}
|
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@CurrentUser() user: { organizationId: string }) {
|
list(
|
||||||
return this.pipelinesService.list(user.organizationId);
|
@CurrentUser() user: Actor,
|
||||||
|
@Query('forLens') forLens?: string,
|
||||||
|
) {
|
||||||
|
return this.pipelinesService.list(user, forLens === '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('board')
|
@Get('board')
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
|
ActivityStatus,
|
||||||
|
ActivityType,
|
||||||
|
DealActivityCue,
|
||||||
|
DealActivitySummaryDto,
|
||||||
DealDto,
|
DealDto,
|
||||||
MergePipelineResultDto,
|
MergePipelineResultDto,
|
||||||
PipelineBoardDto,
|
PipelineBoardDto,
|
||||||
@@ -102,24 +106,55 @@ export class PipelinesService {
|
|||||||
return this.toPipelineDto(created);
|
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);
|
await this.ensureDefaultPipeline(organizationId);
|
||||||
const rows = await this.prisma.pipeline.findMany({
|
const rows = await this.prisma.pipeline.findMany({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
include: { stages: { orderBy: { position: 'asc' } } },
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||||
});
|
});
|
||||||
const counts = await this.prisma.deal.groupBy({
|
const lens = actor?.priorLens;
|
||||||
by: ['pipelineId'],
|
const lensActive = forLens && (lens === 'current' || lens === 'archive');
|
||||||
where: { organizationId },
|
const access = actor ? dealAccessWhere(actor) : {};
|
||||||
_count: { _all: true },
|
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(
|
const dealCountByPipeline = new Map(
|
||||||
counts.map((row) => [row.pipelineId, row._count._all]),
|
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),
|
...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 rateMap = await loadOrgRates(this.prisma, organizationId);
|
||||||
|
const activityHints = await this.loadDealActivityHints(
|
||||||
|
organizationId,
|
||||||
|
deals.map((deal) => deal.id),
|
||||||
|
);
|
||||||
|
|
||||||
const byStage = new Map<string, DealDto[]>();
|
const byStage = new Map<string, DealDto[]>();
|
||||||
for (const stage of resolved.stages) byStage.set(stage.id, []);
|
for (const stage of resolved.stages) byStage.set(stage.id, []);
|
||||||
for (const deal of deals) {
|
for (const deal of deals) {
|
||||||
const list = byStage.get(deal.stageId);
|
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 {
|
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 {
|
toDealDto(row: DealRow): DealDto {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} 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 {
|
export class PlatformLoginDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
|
|||||||
quotaGb!: number | null;
|
quotaGb!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApplyProductPlanDto {
|
||||||
|
@IsIn(PRODUCT_PLAN_CODES)
|
||||||
|
planCode!: ProductPlanCode;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateOrgModulesDto {
|
export class UpdateOrgModulesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
ApplyBillingPlanDto,
|
ApplyBillingPlanDto,
|
||||||
|
ApplyProductPlanDto,
|
||||||
SetStorageQuotaDto,
|
SetStorageQuotaDto,
|
||||||
SetUserCapDto,
|
SetUserCapDto,
|
||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
@@ -168,6 +169,11 @@ export class PlatformController {
|
|||||||
return this.platform.setUserCap(id, dto.maxUsers);
|
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')
|
@Patch('organizations/:id/modules')
|
||||||
updateOrganizationModules(
|
updateOrganizationModules(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
PlatformStatsDto,
|
PlatformStatsDto,
|
||||||
PlatformUserDto,
|
PlatformUserDto,
|
||||||
|
ProductPlanCode,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { isBusinessField } from '@mento/shared';
|
import { isBusinessField } from '@mento/shared';
|
||||||
@@ -472,6 +473,16 @@ export class PlatformService {
|
|||||||
return this.getUser(userId);
|
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) {
|
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||||
const existing = await this.prisma.organization.findUnique({
|
const existing = await this.prisma.organization.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -532,6 +543,9 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||||
if (isActive && !row.isActive) {
|
if (isActive && !row.isActive) {
|
||||||
|
if (row.role === 'owner') {
|
||||||
|
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||||
|
}
|
||||||
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
||||||
}
|
}
|
||||||
if (!isActive && row.role === 'owner') {
|
if (!isActive && row.role === 'owner') {
|
||||||
@@ -562,6 +576,9 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
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') {
|
if (row.role === 'owner' && role !== 'owner') {
|
||||||
const owners = await this.memberships.countActiveOwners(
|
const owners = await this.memberships.countActiveOwners(
|
||||||
row.organizationId,
|
row.organizationId,
|
||||||
@@ -569,7 +586,7 @@ export class PlatformService {
|
|||||||
);
|
);
|
||||||
if (owners < 1) {
|
if (owners < 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { customersPersonWhere } from '../common/data-access';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
assigneeWhere,
|
assigneeWhere,
|
||||||
@@ -54,7 +55,7 @@ export class ReportsService {
|
|||||||
const invoiceScope = ownerWhere(actor, 'finance');
|
const invoiceScope = ownerWhere(actor, 'finance');
|
||||||
const activityScope = assigneeWhere(actor);
|
const activityScope = assigneeWhere(actor);
|
||||||
const ticketScope = ticketAccessWhere(actor);
|
const ticketScope = ticketAccessWhere(actor);
|
||||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
const personScope = customersPersonWhere(actor);
|
||||||
|
|
||||||
const durationMs = end.getTime() - start.getTime();
|
const durationMs = end.getTime() - start.getTime();
|
||||||
const prevEnd = new Date(start.getTime() - 1);
|
const prevEnd = new Date(start.getTime() - 1);
|
||||||
@@ -1534,7 +1535,7 @@ export class ReportsService {
|
|||||||
const orgId = actor.organizationId;
|
const orgId = actor.organizationId;
|
||||||
const dealScope = ownerWhere(actor, 'deals');
|
const dealScope = ownerWhere(actor, 'deals');
|
||||||
const activityScope = assigneeWhere(actor);
|
const activityScope = assigneeWhere(actor);
|
||||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
const personScope = customersPersonWhere(actor);
|
||||||
const rates = await loadOrgRates(this.prisma, orgId);
|
const rates = await loadOrgRates(this.prisma, orgId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { jy, jm } = dateToJalali(now);
|
const { jy, jm } = dateToJalali(now);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{
|
|||||||
export const routineInclude = {
|
export const routineInclude = {
|
||||||
category: true,
|
category: true,
|
||||||
assignee: userSelect,
|
assignee: userSelect,
|
||||||
|
createdBy: userSelect,
|
||||||
team: { select: { id: true, name: true } },
|
team: { select: { id: true, name: true } },
|
||||||
} satisfies Prisma.RoutineInclude;
|
} satisfies Prisma.RoutineInclude;
|
||||||
|
|
||||||
@@ -186,6 +187,7 @@ export function toRoutineDto(
|
|||||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
createdById: row.createdById,
|
createdById: row.createdById,
|
||||||
|
createdBy: row.createdBy,
|
||||||
recurrenceLabel: describeRecurrence(rule),
|
recurrenceLabel: describeRecurrence(rule),
|
||||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||||
executionTotal: execution.total,
|
executionTotal: execution.total,
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export class RoutinesService {
|
|||||||
category: _cat,
|
category: _cat,
|
||||||
assignee: _a,
|
assignee: _a,
|
||||||
team: _t,
|
team: _t,
|
||||||
|
createdBy: _cb,
|
||||||
deletedAt: _d,
|
deletedAt: _d,
|
||||||
...rest
|
...rest
|
||||||
} = src;
|
} = src;
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ export class SmsCampaignsService {
|
|||||||
const leadIds = batch
|
const leadIds = batch
|
||||||
.map((r) => r.leadId)
|
.map((r) => r.leadId)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
const [people, leads] = await Promise.all([
|
const [people, leads, org] = await Promise.all([
|
||||||
personIds.length
|
personIds.length
|
||||||
? this.prisma.person.findMany({
|
? this.prisma.person.findMany({
|
||||||
where: { id: { in: personIds } },
|
where: { id: { in: personIds } },
|
||||||
@@ -535,9 +535,14 @@ export class SmsCampaignsService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
this.prisma.organization.findUnique({
|
||||||
|
where: { id: campaign.organizationId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const personMap = new Map(people.map((p) => [p.id, p]));
|
const personMap = new Map(people.map((p) => [p.id, p]));
|
||||||
const leadMap = new Map(leads.map((l) => [l.id, l]));
|
const leadMap = new Map(leads.map((l) => [l.id, l]));
|
||||||
|
const fromOrg = org?.name?.trim() || '';
|
||||||
|
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -572,6 +577,7 @@ export class SmsCampaignsService {
|
|||||||
phone: recipient.mobile,
|
phone: recipient.mobile,
|
||||||
title,
|
title,
|
||||||
company,
|
company,
|
||||||
|
from: fromOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalized = await this.tracking.personalizeSmsBody({
|
const personalized = await this.tracking.personalizeSmsBody({
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
|
||||||
|
|
||||||
|
export type InvoicePartyPosition = {
|
||||||
|
key: string;
|
||||||
|
partyKind: 'person' | 'company';
|
||||||
|
partyName: string;
|
||||||
|
/** فاکتورهای عادی بهعلاوهٔ ماندهٔ اول دورهٔ مثبت */
|
||||||
|
billed: Prisma.Decimal;
|
||||||
|
/** برگشتیها و اضافهدریافت، برای ستون تسویهشدهٔ بدهکار */
|
||||||
|
offset: Prisma.Decimal;
|
||||||
|
/** مثبت = طلب ما، منفی = بدهی ما */
|
||||||
|
net: Prisma.Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
key: string;
|
||||||
|
partyKind: 'person' | 'company';
|
||||||
|
partyName: string;
|
||||||
|
sales: Prisma.Decimal;
|
||||||
|
credits: Prisma.Decimal;
|
||||||
|
paid: Prisma.Decimal;
|
||||||
|
opening: Prisma.Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
function zero() {
|
||||||
|
return new Prisma.Decimal(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: Prisma.Decimal) {
|
||||||
|
return value.toDecimalPlaces(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همان فرمول ماندهٔ فاکتور: جمع ردیفها − تخفیف + مالیات، برگشتی منفی. */
|
||||||
|
export function signedInvoicePayable(invoice: {
|
||||||
|
kind: string;
|
||||||
|
discountAmount: Prisma.Decimal;
|
||||||
|
taxPercent: Prisma.Decimal;
|
||||||
|
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
|
||||||
|
}) {
|
||||||
|
const subtotal = invoice.lineItems.reduce(
|
||||||
|
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
|
||||||
|
zero(),
|
||||||
|
);
|
||||||
|
const discount = invoice.discountAmount ?? zero();
|
||||||
|
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
|
||||||
|
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
|
||||||
|
const signed = round2(subtotal.minus(discount).plus(tax));
|
||||||
|
return invoice.kind === 'credit' ? signed.neg() : signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
|
||||||
|
const connections = await prisma.accountingConnection.count({
|
||||||
|
where: { organizationId, enabled: true },
|
||||||
|
});
|
||||||
|
return connections === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ماندهٔ طرفحساب از فاکتور صادرشده و ماندهٔ اول دوره.
|
||||||
|
* سند درآمد جدا ساخته نمیشود تا همان فروش دو بار طلب نشود.
|
||||||
|
* اگر حسابداری بیرونی وصل باشد، خالی برمیگردد.
|
||||||
|
*/
|
||||||
|
export async function loadInvoicePartyPositions(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<InvoicePartyPosition[]> {
|
||||||
|
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
|
||||||
|
|
||||||
|
const [invoices, openings] = await Promise.all([
|
||||||
|
prisma.invoice.findMany({
|
||||||
|
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||||
|
select: {
|
||||||
|
kind: true,
|
||||||
|
discountAmount: true,
|
||||||
|
taxPercent: true,
|
||||||
|
personId: true,
|
||||||
|
companyId: true,
|
||||||
|
person: { select: { firstName: true, lastName: true } },
|
||||||
|
company: { select: { name: true } },
|
||||||
|
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||||
|
payments: { select: { amount: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.partyOpeningBalance.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
select: {
|
||||||
|
personId: true,
|
||||||
|
amount: true,
|
||||||
|
person: { select: { firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
const ensure = (
|
||||||
|
key: string,
|
||||||
|
partyKind: 'person' | 'company',
|
||||||
|
partyName: string,
|
||||||
|
) => {
|
||||||
|
const current = buckets.get(key);
|
||||||
|
if (current) return current;
|
||||||
|
const created: Bucket = {
|
||||||
|
key,
|
||||||
|
partyKind,
|
||||||
|
partyName,
|
||||||
|
sales: zero(),
|
||||||
|
credits: zero(),
|
||||||
|
paid: zero(),
|
||||||
|
opening: zero(),
|
||||||
|
};
|
||||||
|
buckets.set(key, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const personId = invoice.personId;
|
||||||
|
const companyId = invoice.companyId;
|
||||||
|
const party = personId
|
||||||
|
? {
|
||||||
|
key: `person:${personId}`,
|
||||||
|
partyKind: 'person' as const,
|
||||||
|
partyName:
|
||||||
|
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
|
||||||
|
'مشتری',
|
||||||
|
}
|
||||||
|
: companyId
|
||||||
|
? {
|
||||||
|
key: `company:${companyId}`,
|
||||||
|
partyKind: 'company' as const,
|
||||||
|
partyName: invoice.company?.name?.trim() || 'شرکت',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!party) continue;
|
||||||
|
const bucket = ensure(party.key, party.partyKind, party.partyName);
|
||||||
|
const payable = signedInvoicePayable(invoice);
|
||||||
|
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
|
||||||
|
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
|
||||||
|
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
|
||||||
|
bucket.paid = bucket.paid.plus(paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opening of openings) {
|
||||||
|
if (opening.amount.equals(0)) continue;
|
||||||
|
const bucket = ensure(
|
||||||
|
`person:${opening.personId}`,
|
||||||
|
'person',
|
||||||
|
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
|
||||||
|
);
|
||||||
|
bucket.opening = bucket.opening.plus(opening.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: InvoicePartyPosition[] = [];
|
||||||
|
for (const bucket of buckets.values()) {
|
||||||
|
const net = round2(
|
||||||
|
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
|
||||||
|
);
|
||||||
|
if (net.equals(0)) continue;
|
||||||
|
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
|
||||||
|
const offset = round2(billed.minus(net));
|
||||||
|
positions.push({
|
||||||
|
key: bucket.key,
|
||||||
|
partyKind: bucket.partyKind,
|
||||||
|
partyName: bucket.partyName,
|
||||||
|
billed,
|
||||||
|
offset: Prisma.Decimal.max(offset, 0),
|
||||||
|
net,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمیکند. */
|
||||||
|
export async function invoiceSalesInPeriod(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
from: Date | null,
|
||||||
|
to: Date | null,
|
||||||
|
) {
|
||||||
|
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
|
||||||
|
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
|
||||||
|
|
||||||
|
const invoices = await prisma.invoice.findMany({
|
||||||
|
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||||
|
select: {
|
||||||
|
kind: true,
|
||||||
|
discountAmount: true,
|
||||||
|
taxPercent: true,
|
||||||
|
issuedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let sales = zero();
|
||||||
|
let credits = zero();
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const when = invoice.issuedAt ?? invoice.createdAt;
|
||||||
|
if (from && when < from) continue;
|
||||||
|
if (to && when > to) continue;
|
||||||
|
const payable = signedInvoicePayable(invoice);
|
||||||
|
if (payable.greaterThan(0)) sales = sales.plus(payable);
|
||||||
|
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sales: round2(sales).toFixed(2),
|
||||||
|
credits: round2(credits).toFixed(2),
|
||||||
|
net: round2(sales.minus(credits)).toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
parseUserTreasuryAccess,
|
parseUserTreasuryAccess,
|
||||||
|
permissionsWithTreasuryOverride,
|
||||||
treasurySectionAllowed,
|
treasurySectionAllowed,
|
||||||
type TreasurySectionKey,
|
type TreasurySectionKey,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
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;
|
const actor = req.user;
|
||||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
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)
|
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||||
? membership.orgRole.permissions.map(String)
|
? membership.orgRole.permissions.map(String)
|
||||||
: [];
|
: [];
|
||||||
const permissions = permissionsForTreasury(
|
const permissions = permissionsWithTreasuryOverride(
|
||||||
rolePermissions,
|
rolePermissions,
|
||||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||||
);
|
);
|
||||||
|
const method = (req.method || 'GET').toUpperCase();
|
||||||
const url = req.originalUrl || req.url || '';
|
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))) {
|
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||||
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||||
}
|
}
|
||||||
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionsForTreasury(
|
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریتشان جدا میماند. */
|
||||||
rolePermissions: string[],
|
function isLookupForDocuments(method: string, url: string): boolean {
|
||||||
override: ReturnType<typeof parseUserTreasuryAccess>,
|
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||||
): string[] {
|
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||||
if (!override) return rolePermissions;
|
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
|
||||||
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
|
|
||||||
if (!override.enabled) return rest;
|
|
||||||
return ['treasury', ...override.sections, ...rest];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
|
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
|
|||||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { invoiceSalesInPeriod } from './invoice-ledger';
|
||||||
|
|
||||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||||
return new Prisma.Decimal(value ?? 0);
|
return new Prisma.Decimal(value ?? 0);
|
||||||
@@ -12,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
|||||||
function money(value: Prisma.Decimal | number | string) {
|
function money(value: Prisma.Decimal | number | string) {
|
||||||
return new Prisma.Decimal(value).toFixed(2);
|
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) {
|
function parseDate(iso?: string | null) {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return Number.isNaN(d.getTime()) ? null : d;
|
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) {
|
function periodKey(date: Date) {
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -36,9 +46,15 @@ export class TreasuryOpsService {
|
|||||||
async listPayroll(actor: Actor) {
|
async listPayroll(actor: Actor) {
|
||||||
const rows = await this.prisma.financialPayroll.findMany({
|
const rows = await this.prisma.financialPayroll.findMany({
|
||||||
where: { organizationId: actor.organizationId },
|
where: { organizationId: actor.organizationId },
|
||||||
include: { movements: true },
|
include: {
|
||||||
|
movements: {
|
||||||
|
where: { voidedAt: null },
|
||||||
|
include: { account: { select: { name: true } } },
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { dueDate: 'desc' },
|
orderBy: { dueDate: 'desc' },
|
||||||
take: 100,
|
take: 500,
|
||||||
});
|
});
|
||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
where: { id: { in: rows.map((row) => row.userId) } },
|
where: { id: { in: rows.map((row) => row.userId) } },
|
||||||
@@ -55,8 +71,11 @@ export class TreasuryOpsService {
|
|||||||
period: string;
|
period: string;
|
||||||
baseAmount: string | number;
|
baseAmount: string | number;
|
||||||
benefits?: string | number;
|
benefits?: string | number;
|
||||||
|
benefitsReason?: string | null;
|
||||||
bonus?: string | number;
|
bonus?: string | number;
|
||||||
|
bonusReason?: string | null;
|
||||||
deductions?: string | number;
|
deductions?: string | number;
|
||||||
|
deductionsReason?: string | null;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
},
|
},
|
||||||
@@ -81,14 +100,23 @@ export class TreasuryOpsService {
|
|||||||
period: input.period.trim(),
|
period: input.period.trim(),
|
||||||
baseAmount: base,
|
baseAmount: base,
|
||||||
benefits,
|
benefits,
|
||||||
|
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
||||||
bonus,
|
bonus,
|
||||||
|
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
||||||
deductions,
|
deductions,
|
||||||
|
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
||||||
payable,
|
payable,
|
||||||
dueDate: parseDate(input.dueDate) ?? new Date(),
|
dueDate: parseDate(input.dueDate) ?? new Date(),
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
createdById: actor.userId,
|
createdById: actor.userId,
|
||||||
},
|
},
|
||||||
include: { movements: true },
|
include: {
|
||||||
|
movements: {
|
||||||
|
where: { voidedAt: null },
|
||||||
|
include: { account: { select: { name: true } } },
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
||||||
return this.payrollDto(row, user?.fullName ?? '');
|
return this.payrollDto(row, user?.fullName ?? '');
|
||||||
@@ -124,7 +152,7 @@ export class TreasuryOpsService {
|
|||||||
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||||
const balance = await this.balance(account.id);
|
const balance = await this.balance(account.id);
|
||||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
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([
|
await this.prisma.$transaction([
|
||||||
this.prisma.financialMovement.create({
|
this.prisma.financialMovement.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -145,6 +173,123 @@ export class TreasuryOpsService {
|
|||||||
return this.listPayroll(actor);
|
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) {
|
async listRecurring(actor: Actor) {
|
||||||
const rows = await this.prisma.financialRecurringItem.findMany({
|
const rows = await this.prisma.financialRecurringItem.findMany({
|
||||||
where: { organizationId: actor.organizationId },
|
where: { organizationId: actor.organizationId },
|
||||||
@@ -327,6 +472,8 @@ export class TreasuryOpsService {
|
|||||||
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
||||||
const rows: Array<[string, string, string]> = [
|
const rows: Array<[string, string, string]> = [
|
||||||
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
||||||
|
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
|
||||||
|
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
|
||||||
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
||||||
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
||||||
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
||||||
@@ -411,10 +558,15 @@ export class TreasuryOpsService {
|
|||||||
amount: money(amount),
|
amount: money(amount),
|
||||||
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
||||||
}));
|
}));
|
||||||
|
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
|
||||||
return {
|
return {
|
||||||
bookedIncome: money(bookedIncome),
|
bookedIncome: money(bookedIncome),
|
||||||
|
invoiceSales: invoiceSales.sales,
|
||||||
|
invoiceCredits: invoiceSales.credits,
|
||||||
bookedExpense: money(bookedExpense),
|
bookedExpense: money(bookedExpense),
|
||||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
operatingProfit: money(
|
||||||
|
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
|
||||||
|
),
|
||||||
cashIn: money(cashIn),
|
cashIn: money(cashIn),
|
||||||
cashOut: money(cashOut),
|
cashOut: money(cashOut),
|
||||||
cashNet: money(cashIn.minus(cashOut)),
|
cashNet: money(cashIn.minus(cashOut)),
|
||||||
@@ -475,12 +627,22 @@ export class TreasuryOpsService {
|
|||||||
period: string;
|
period: string;
|
||||||
baseAmount: Prisma.Decimal;
|
baseAmount: Prisma.Decimal;
|
||||||
benefits: Prisma.Decimal;
|
benefits: Prisma.Decimal;
|
||||||
|
benefitsReason?: string | null;
|
||||||
bonus: Prisma.Decimal;
|
bonus: Prisma.Decimal;
|
||||||
|
bonusReason?: string | null;
|
||||||
deductions: Prisma.Decimal;
|
deductions: Prisma.Decimal;
|
||||||
|
deductionsReason?: string | null;
|
||||||
payable: Prisma.Decimal;
|
payable: Prisma.Decimal;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
movements: Array<{
|
||||||
|
id: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
voidedAt: Date | null;
|
||||||
|
accountId: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
account?: { name: string } | null;
|
||||||
|
}>;
|
||||||
},
|
},
|
||||||
fullName: string,
|
fullName: string,
|
||||||
) {
|
) {
|
||||||
@@ -493,13 +655,25 @@ export class TreasuryOpsService {
|
|||||||
period: row.period,
|
period: row.period,
|
||||||
baseAmount: money(row.baseAmount),
|
baseAmount: money(row.baseAmount),
|
||||||
benefits: money(row.benefits),
|
benefits: money(row.benefits),
|
||||||
|
benefitsReason: row.benefitsReason?.trim() || null,
|
||||||
bonus: money(row.bonus),
|
bonus: money(row.bonus),
|
||||||
|
bonusReason: row.bonusReason?.trim() || null,
|
||||||
deductions: money(row.deductions),
|
deductions: money(row.deductions),
|
||||||
|
deductionsReason: row.deductionsReason?.trim() || null,
|
||||||
payable: money(row.payable),
|
payable: money(row.payable),
|
||||||
paidAmount: money(paid),
|
paidAmount: money(paid),
|
||||||
remaining: money(left.isNegative() ? 0 : left),
|
remaining: money(left.isNegative() ? 0 : left),
|
||||||
dueDate: row.dueDate.toISOString(),
|
dueDate: row.dueDate.toISOString(),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
payments: row.movements
|
||||||
|
.filter((item) => !item.voidedAt)
|
||||||
|
.map((item) => ({
|
||||||
|
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');
|
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')
|
@Get('documents')
|
||||||
documents(
|
documents(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@@ -232,6 +255,25 @@ export class TreasuryController {
|
|||||||
return this.ops.createPayroll(user, body);
|
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')
|
@Post('payroll/:id/pay')
|
||||||
payPayroll(
|
payPayroll(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
|
||||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||||
|
|
||||||
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
|
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
|
||||||
@@ -359,6 +360,7 @@ export class TreasuryService {
|
|||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
accountId,
|
accountId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
|
kind: { not: 'allocation' },
|
||||||
...(from || to
|
...(from || to
|
||||||
? {
|
? {
|
||||||
occurredAt: {
|
occurredAt: {
|
||||||
@@ -377,6 +379,7 @@ export class TreasuryService {
|
|||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
accountId,
|
accountId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
|
kind: { not: 'allocation' },
|
||||||
occurredAt: { lt: from },
|
occurredAt: { lt: from },
|
||||||
},
|
},
|
||||||
select: { direction: true, amount: true },
|
select: { direction: true, amount: true },
|
||||||
@@ -530,6 +533,8 @@ export class TreasuryService {
|
|||||||
settleAmount?: string | number | null;
|
settleAmount?: string | number | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
settledAt?: string | null;
|
settledAt?: string | null;
|
||||||
|
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمیسازد. */
|
||||||
|
settlement?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<FinancialDocumentDto> {
|
): Promise<FinancialDocumentDto> {
|
||||||
const title = input.title.trim();
|
const title = input.title.trim();
|
||||||
@@ -560,6 +565,17 @@ export class TreasuryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
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 doc = await this.prisma.$transaction(async (tx) => {
|
||||||
const reference = await this.nextDocumentReference(
|
const reference = await this.nextDocumentReference(
|
||||||
tx,
|
tx,
|
||||||
@@ -606,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({
|
return tx.financialDocument.findUniqueOrThrow({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: { category: true, account: true, movements: true },
|
include: { category: true, account: true, movements: true },
|
||||||
@@ -727,6 +782,7 @@ export class TreasuryService {
|
|||||||
amount: Prisma.Decimal;
|
amount: Prisma.Decimal;
|
||||||
settled: Prisma.Decimal;
|
settled: Prisma.Decimal;
|
||||||
remaining: Prisma.Decimal;
|
remaining: Prisma.Decimal;
|
||||||
|
documentRemaining: Prisma.Decimal;
|
||||||
}>();
|
}>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.partyKind === 'other') continue;
|
if (row.partyKind === 'other') continue;
|
||||||
@@ -747,13 +803,43 @@ export class TreasuryService {
|
|||||||
amount: new Prisma.Decimal(0),
|
amount: new Prisma.Decimal(0),
|
||||||
settled: new Prisma.Decimal(0),
|
settled: new Prisma.Decimal(0),
|
||||||
remaining: new Prisma.Decimal(0),
|
remaining: new Prisma.Decimal(0),
|
||||||
|
documentRemaining: new Prisma.Decimal(0),
|
||||||
};
|
};
|
||||||
current.amount = current.amount.plus(row.amount);
|
current.amount = current.amount.plus(row.amount);
|
||||||
current.settled = current.settled.plus(settled);
|
current.settled = current.settled.plus(settled);
|
||||||
current.remaining = current.remaining.plus(rawRemaining);
|
current.remaining = current.remaining.plus(rawRemaining);
|
||||||
|
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
|
||||||
buckets.set(key, current);
|
buckets.set(key, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
|
||||||
|
for (const party of invoiceParties) {
|
||||||
|
const onDebtorSide = party.net.greaterThan(0);
|
||||||
|
if (kind === 'income' && !onDebtorSide) continue;
|
||||||
|
if (kind === 'expense' && onDebtorSide) continue;
|
||||||
|
const remaining = party.net.abs();
|
||||||
|
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
|
||||||
|
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
|
||||||
|
const current = buckets.get(party.key) ?? {
|
||||||
|
key: party.key,
|
||||||
|
partyKind: party.partyKind,
|
||||||
|
partyName: party.partyName,
|
||||||
|
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);
|
||||||
|
current.remaining = current.remaining.plus(remaining);
|
||||||
|
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
|
||||||
|
current.partyName = party.partyName;
|
||||||
|
}
|
||||||
|
buckets.set(party.key, current);
|
||||||
|
}
|
||||||
|
|
||||||
return [...buckets.values()]
|
return [...buckets.values()]
|
||||||
|
.filter((row) => row.remaining.greaterThan(0))
|
||||||
.sort((a, b) => b.remaining.comparedTo(a.remaining))
|
.sort((a, b) => b.remaining.comparedTo(a.remaining))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
key: row.key,
|
key: row.key,
|
||||||
@@ -762,9 +848,122 @@ export class TreasuryService {
|
|||||||
amount: money(row.amount),
|
amount: money(row.amount),
|
||||||
settledAmount: money(row.settled),
|
settledAmount: money(row.settled),
|
||||||
remaining: money(row.remaining),
|
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(
|
private async resolveParty(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
input: {
|
input: {
|
||||||
@@ -908,16 +1107,51 @@ export class TreasuryService {
|
|||||||
});
|
});
|
||||||
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction(async (tx) => {
|
||||||
this.prisma.financialMovement.updateMany({
|
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 },
|
where: { documentId: doc.id, voidedAt: null },
|
||||||
data: { voidedAt: now },
|
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 },
|
where: { id: doc.id },
|
||||||
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
|
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({
|
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
|
||||||
where: { id: doc.id },
|
where: { id: doc.id },
|
||||||
include: { category: true, account: true, movements: true },
|
include: { category: true, account: true, movements: true },
|
||||||
@@ -1092,6 +1326,17 @@ export class TreasuryService {
|
|||||||
}
|
}
|
||||||
const bookedIncome = dec(income._sum.amount);
|
const bookedIncome = dec(income._sum.amount);
|
||||||
const bookedExpense = dec(expense._sum.amount);
|
const bookedExpense = dec(expense._sum.amount);
|
||||||
|
const invoiceSales = await invoiceSalesInPeriod(
|
||||||
|
this.prisma,
|
||||||
|
actor.organizationId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
|
||||||
|
for (const party of invoiceParties) {
|
||||||
|
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
|
||||||
|
else payables = payables.plus(party.net.abs());
|
||||||
|
}
|
||||||
const totalBalance = accounts.reduce(
|
const totalBalance = accounts.reduce(
|
||||||
(sum, account) => sum.plus(account.balance),
|
(sum, account) => sum.plus(account.balance),
|
||||||
new Prisma.Decimal(0),
|
new Prisma.Decimal(0),
|
||||||
@@ -1101,7 +1346,7 @@ export class TreasuryService {
|
|||||||
actualReceipts: money(dec(receipts._sum.amount)),
|
actualReceipts: money(dec(receipts._sum.amount)),
|
||||||
bookedExpense: money(bookedExpense),
|
bookedExpense: money(bookedExpense),
|
||||||
actualPayments: money(dec(payments._sum.amount)),
|
actualPayments: money(dec(payments._sum.amount)),
|
||||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
|
||||||
receivables: money(receivables),
|
receivables: money(receivables),
|
||||||
payables: money(payables),
|
payables: money(payables),
|
||||||
totalBalance: money(totalBalance),
|
totalBalance: money(totalBalance),
|
||||||
@@ -1111,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 }>) {
|
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
|
||||||
return movements
|
return movements
|
||||||
.filter((row) => !row.voidedAt)
|
.filter((row) => !row.voidedAt)
|
||||||
@@ -1119,7 +1434,7 @@ export class TreasuryService {
|
|||||||
|
|
||||||
private async accountBalance(accountId: string) {
|
private async accountBalance(accountId: string) {
|
||||||
const rows = await this.prisma.financialMovement.findMany({
|
const rows = await this.prisma.financialMovement.findMany({
|
||||||
where: { accountId, voidedAt: null },
|
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
|
||||||
select: { direction: true, amount: true },
|
select: { direction: true, amount: true },
|
||||||
});
|
});
|
||||||
return rows.reduce(
|
return rows.reduce(
|
||||||
@@ -1194,7 +1509,7 @@ export class TreasuryService {
|
|||||||
const from = startOfDay(range?.from);
|
const from = startOfDay(range?.from);
|
||||||
const to = startOfDay(range?.to);
|
const to = startOfDay(range?.to);
|
||||||
const movements = await this.prisma.financialMovement.findMany({
|
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 },
|
select: { direction: true, amount: true, kind: true, occurredAt: true },
|
||||||
});
|
});
|
||||||
let balance = new Prisma.Decimal(0);
|
let balance = new Prisma.Decimal(0);
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export class UsersService {
|
|||||||
orgRoleId: dto.orgRoleId,
|
orgRoleId: dto.orgRoleId,
|
||||||
role: dto.role,
|
role: dto.role,
|
||||||
});
|
});
|
||||||
if (resolved.role === 'owner' && actorRole !== 'owner') {
|
if (resolved.role === 'owner') {
|
||||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
throw new BadRequestException(
|
||||||
|
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
|
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') {
|
if (target.role === 'owner' && nextRole !== 'owner') {
|
||||||
const owners = await this.memberships.countActiveOwners(
|
const owners = await this.memberships.countActiveOwners(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -456,7 +462,7 @@ export class UsersService {
|
|||||||
);
|
);
|
||||||
if (owners < 1) {
|
if (owners < 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,6 +534,9 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isActive && !target.isActive) {
|
if (isActive && !target.isActive) {
|
||||||
|
if (target.role === 'owner') {
|
||||||
|
await this.memberships.assertSoleOwner(organizationId, targetId);
|
||||||
|
}
|
||||||
await this.billing.assertSeatAvailable(organizationId, targetId);
|
await this.billing.assertSeatAvailable(organizationId, targetId);
|
||||||
}
|
}
|
||||||
await this.prisma.membership.update({
|
await this.prisma.membership.update({
|
||||||
@@ -558,6 +567,9 @@ export class UsersService {
|
|||||||
resolved: { role: UserRole; orgRoleId: string },
|
resolved: { role: UserRole; orgRoleId: string },
|
||||||
): Promise<OrgUserDto> {
|
): Promise<OrgUserDto> {
|
||||||
await this.billing.assertSeatAvailable(organizationId, userId);
|
await this.billing.assertSeatAvailable(organizationId, userId);
|
||||||
|
if (resolved.role === 'owner') {
|
||||||
|
await this.memberships.assertSoleOwner(organizationId, userId);
|
||||||
|
}
|
||||||
const existing = await this.prisma.membership.findFirst({
|
const existing = await this.prisma.membership.findFirst({
|
||||||
where: { userId, organizationId },
|
where: { userId, organizationId },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ describe('اتوماسیون پیام', () => {
|
|||||||
'سلام علی از منتکت - {unknown}',
|
'سلام علی از منتکت - {unknown}',
|
||||||
);
|
);
|
||||||
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
|
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
|
||||||
|
assert.equal(
|
||||||
|
applyMessageTemplate('از طرف {از طرف}', { from: 'هنراندیشه' }),
|
||||||
|
'از طرف هنراندیشه',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('متن پیامک بریده نمیشود', () => {
|
it('متن پیامک بریده نمیشود', () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 {
|
import {
|
||||||
noteAccessWhere,
|
noteAccessWhere,
|
||||||
normalizeDataVisibility,
|
normalizeDataVisibility,
|
||||||
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخشها را ذ
|
|||||||
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
|
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>
|
<strong>{{ hasSort ? 'فیلتر و مرتبسازی' : 'فیلترها' }}</strong>
|
||||||
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
|
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
|
||||||
</header>
|
</header>
|
||||||
|
<p v-if="isPeople" class="facet-hint">
|
||||||
|
گروه، منبع و وضعیت با هم جمع میشوند؛ هر کدام کافی است (بدون تکرار).
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<section v-if="hasSort" class="sec">
|
<section v-if="hasSort" class="sec">
|
||||||
@@ -264,6 +267,15 @@ function apply() {
|
|||||||
border-bottom: 1px solid var(--line);
|
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 {
|
.panel-foot {
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -1,29 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
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 { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
import { normalizeSearchText } from '@/lib/search';
|
||||||
import {
|
import {
|
||||||
CUSTOMER_GROUP_COLOR_LABELS,
|
CUSTOMER_GROUP_COLOR_LABELS,
|
||||||
groupSwatch,
|
groupSwatch,
|
||||||
useCustomerGroups,
|
useCustomerGroups,
|
||||||
} from '@/lib/customer-groups';
|
} from '@/lib/customer-groups';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { UiButton, UiInput, UiModal } from '@/components/ui';
|
import { UiButton, UiInput, UiModal } from '@/components/ui';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
|
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean }>();
|
const props = defineProps<{ open: boolean }>();
|
||||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const canManage = computed(
|
||||||
|
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
|
||||||
|
);
|
||||||
|
|
||||||
const { groups, load } = useCustomerGroups();
|
const { groups, load } = useCustomerGroups();
|
||||||
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const newName = ref('');
|
const newName = ref('');
|
||||||
const newColor = ref<CustomerGroupColor>('teal');
|
const newColor = ref<CustomerGroupColor>('teal');
|
||||||
|
const newAssigneeIds = ref<string[]>([]);
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
const editName = ref('');
|
const editName = ref('');
|
||||||
|
const assigneeDraft = ref<Record<string, string[]>>({});
|
||||||
|
const listQuery = ref('');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref('');
|
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) {
|
function errorText(e: unknown, fallback: string) {
|
||||||
if (axios.isAxiosError(e)) {
|
if (axios.isAxiosError(e)) {
|
||||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||||
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
|
|||||||
return fallback;
|
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) {
|
async function run(task: () => Promise<unknown>, fallback: string) {
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await task();
|
await task();
|
||||||
await load(true);
|
await load(true);
|
||||||
|
syncAssigneeDraft();
|
||||||
emit('changed');
|
emit('changed');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -53,10 +96,18 @@ async function create() {
|
|||||||
const name = newName.value.trim();
|
const name = newName.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const ok = await run(
|
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) {
|
function startEdit(g: CustomerGroupDto) {
|
||||||
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
|
|||||||
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
|
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) {
|
async function remove(g: CustomerGroupDto) {
|
||||||
const used = g.personCount + g.companyCount;
|
const used = g.personCount + g.companyCount;
|
||||||
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته میشود (خود مخاطبها حذف نمیشوند).` : '';
|
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته میشود (خود مخاطبها حذف نمیشوند).` : '';
|
||||||
@@ -95,9 +164,12 @@ watch(
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
void load(true).catch(() => {
|
listQuery.value = '';
|
||||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
void Promise.all([load(true), loadUsers()])
|
||||||
});
|
.then(() => syncAssigneeDraft())
|
||||||
|
.catch(() => {
|
||||||
|
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -107,34 +179,60 @@ watch(
|
|||||||
<UiModal :open="open" title="گروههای مشتری" @close="emit('close')">
|
<UiModal :open="open" title="گروههای مشتری" @close="emit('close')">
|
||||||
<div class="manager">
|
<div class="manager">
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
هر شخص یا شرکت میتواند چند گروه داشته باشد. گروههایی که از تهحساب میآیند با برچسب
|
هر شخص یا شرکت میتواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
|
||||||
«تهحساب» مشخص شدهاند.
|
مشتریان خود میبینند. گروههای تهحساب با برچسب «تهحساب» مشخص شدهاند.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="new-row" @submit.prevent="create">
|
<form v-if="canManage" class="create-panel" @submit.prevent="create">
|
||||||
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
<header class="create-head">
|
||||||
<div class="swatches">
|
<strong>گروه جدید</strong>
|
||||||
<button
|
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
|
||||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
</header>
|
||||||
:key="c"
|
<div class="new-row">
|
||||||
type="button"
|
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
||||||
class="swatch"
|
<div class="swatches">
|
||||||
:class="{ on: newColor === c }"
|
<button
|
||||||
:style="{ background: groupSwatch(c).fg }"
|
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||||
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
|
:key="c"
|
||||||
@click="newColor = 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>
|
</div>
|
||||||
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
|
|
||||||
<UiIcon name="plus" :size="15" />
|
|
||||||
افزودن
|
|
||||||
</UiButton>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
<ul v-if="groups.length" class="list">
|
<div v-if="canManage || groups.length" class="list-head">
|
||||||
<li v-for="g in groups" :key="g.id" class="row">
|
<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">
|
<div class="name-cell">
|
||||||
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
|
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
|
||||||
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
|
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
|
||||||
@@ -152,9 +250,10 @@ watch(
|
|||||||
<span class="count">
|
<span class="count">
|
||||||
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
|
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editingId !== g.id" class="row-actions">
|
<div v-if="editingId !== g.id && canManage" class="row-actions">
|
||||||
<div class="swatches">
|
<div class="swatches">
|
||||||
<button
|
<button
|
||||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||||
@@ -173,8 +272,20 @@ watch(
|
|||||||
<UiIcon name="trash" :size="14" />
|
<UiIcon name="trash" :size="14" />
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
|
||||||
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
|
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
|
||||||
</div>
|
</div>
|
||||||
</UiModal>
|
</UiModal>
|
||||||
@@ -200,6 +311,78 @@ watch(
|
|||||||
font-size: 0.85rem;
|
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,
|
.new-row,
|
||||||
.edit {
|
.edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -240,11 +423,9 @@ watch(
|
|||||||
.list {
|
.list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 0 0.25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
max-height: 55vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@@ -282,14 +463,38 @@ watch(
|
|||||||
padding: 0.05rem 0.4rem;
|
padding: 0.05rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
.count,
|
||||||
|
.assignee-summary {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assignee-summary {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: SectionDataAccess;
|
modelValue: SectionDataAccess;
|
||||||
section?: DataAccessSection;
|
section?: DataAccessSection;
|
||||||
groups?: IdOption[];
|
|
||||||
stages?: IdOption[];
|
stages?: IdOption[];
|
||||||
pipelines?: IdOption[];
|
pipelines?: IdOption[];
|
||||||
boards?: IdOption[];
|
boards?: IdOption[];
|
||||||
@@ -36,7 +35,6 @@ const props = withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
section: 'customers',
|
section: 'customers',
|
||||||
groups: () => [],
|
|
||||||
stages: () => [],
|
stages: () => [],
|
||||||
pipelines: () => [],
|
pipelines: () => [],
|
||||||
boards: () => [],
|
boards: () => [],
|
||||||
@@ -53,9 +51,9 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
|
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
|
||||||
|
|
||||||
const showGroups = computed(() => props.section === 'customers');
|
|
||||||
const showMoods = computed(() => props.section === 'customers');
|
const showMoods = computed(() => props.section === 'customers');
|
||||||
const showImportSources = computed(() => props.section === 'customers');
|
const showImportSources = computed(() => props.section === 'customers');
|
||||||
|
const isCustomers = computed(() => props.section === 'customers');
|
||||||
const showLeadSources = computed(() => props.section === 'leads');
|
const showLeadSources = computed(() => props.section === 'leads');
|
||||||
const showNoteTypes = computed(() => props.section === 'notes');
|
const showNoteTypes = computed(() => props.section === 'notes');
|
||||||
const showTicketStatuses = computed(() => props.section === 'tickets');
|
const showTicketStatuses = computed(() => props.section === 'tickets');
|
||||||
@@ -119,9 +117,8 @@ const boardsLegend = computed(() =>
|
|||||||
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینهها) برای ویرایش ساده */
|
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینهها) برای ویرایش ساده */
|
||||||
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
||||||
if (!rules.length) return {};
|
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 owners = new Set<DataAccessOwnerKind>();
|
||||||
const groupIds = new Set<string>();
|
|
||||||
const sources = new Set<string>();
|
const sources = new Set<string>();
|
||||||
const moods = new Set<PersonMood>();
|
const moods = new Set<PersonMood>();
|
||||||
const stageIds = new Set<string>();
|
const stageIds = new Set<string>();
|
||||||
@@ -130,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
|||||||
const typeIds = new Set<string>();
|
const typeIds = new Set<string>();
|
||||||
for (const r of rules) {
|
for (const r of rules) {
|
||||||
for (const o of r.owners ?? []) owners.add(o);
|
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 s of r.sources ?? []) sources.add(s);
|
||||||
for (const m of r.moods ?? []) moods.add(m);
|
for (const m of r.moods ?? []) moods.add(m);
|
||||||
for (const id of r.stageIds ?? []) stageIds.add(id);
|
for (const id of r.stageIds ?? []) stageIds.add(id);
|
||||||
@@ -140,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
|||||||
}
|
}
|
||||||
const out: DataAccessRule = {};
|
const out: DataAccessRule = {};
|
||||||
if (owners.size) out.owners = [...owners];
|
if (owners.size) out.owners = [...owners];
|
||||||
if (groupIds.size) out.groupIds = [...groupIds];
|
|
||||||
if (sources.size) out.sources = [...sources];
|
if (sources.size) out.sources = [...sources];
|
||||||
if (moods.size) out.moods = [...moods];
|
if (moods.size) out.moods = [...moods];
|
||||||
if (stageIds.size) out.stageIds = [...stageIds];
|
if (stageIds.size) out.stageIds = [...stageIds];
|
||||||
@@ -150,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
|
|||||||
return out;
|
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 {
|
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
rule.owners?.length ||
|
rule.owners?.length ||
|
||||||
rule.groupIds?.length ||
|
(!isCustomers.value && rule.groupIds?.length) ||
|
||||||
rule.sources?.length ||
|
rule.sources?.length ||
|
||||||
rule.moods?.length ||
|
rule.moods?.length ||
|
||||||
rule.stageIds?.length ||
|
rule.stageIds?.length ||
|
||||||
@@ -184,10 +186,26 @@ const mode = computed({
|
|||||||
|
|
||||||
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
|
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) {
|
function emitFilter(next: DataAccessRule) {
|
||||||
const cleaned: DataAccessRule = {};
|
const cleaned: DataAccessRule = {};
|
||||||
if (next.owners?.length) cleaned.owners = [...next.owners];
|
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.sources?.length) cleaned.sources = [...next.sources];
|
||||||
if (next.moods?.length) cleaned.moods = [...next.moods];
|
if (next.moods?.length) cleaned.moods = [...next.moods];
|
||||||
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
|
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
|
||||||
@@ -210,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListKey =
|
type ListKey =
|
||||||
| 'groupIds'
|
|
||||||
| 'sources'
|
| 'sources'
|
||||||
| 'moods'
|
| 'moods'
|
||||||
| 'stageIds'
|
| 'stageIds'
|
||||||
@@ -293,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
|
|||||||
const pickers = computed((): Picker[] => {
|
const pickers = computed((): Picker[] => {
|
||||||
const rule = filter.value;
|
const rule = filter.value;
|
||||||
const items: Picker[] = [];
|
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) {
|
if (showImportSources.value) {
|
||||||
const ready = props.importSourcesReady;
|
const ready = props.importSourcesReady;
|
||||||
const failed = props.importSourcesFailed;
|
const failed = props.importSourcesFailed;
|
||||||
@@ -417,7 +424,6 @@ const filterSummary = computed(() => {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
|
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
|
||||||
if (rule.owners?.includes('team')) parts.push('تیمهای عضو');
|
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.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
|
||||||
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
|
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
|
||||||
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپلاین`);
|
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپلاین`);
|
||||||
@@ -443,9 +449,12 @@ const filterSummary = computed(() => {
|
|||||||
|
|
||||||
<template v-if="mode === 'rules'">
|
<template v-if="mode === 'rules'">
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
فقط رکوردهایی دیده میشوند که با
|
فیلترهای پرشده با هم
|
||||||
<strong>همهٔ فیلترهای پرشده</strong>
|
<strong>جمع میشوند</strong>
|
||||||
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
|
؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
|
||||||
|
<template v-if="isCustomers">
|
||||||
|
اعضای گروههایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه میشوند.
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="hadMultipleRules" class="hint warn">
|
<p v-if="hadMultipleRules" class="hint warn">
|
||||||
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
emptySectionDataAccess,
|
emptySectionDataAccess,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
|
||||||
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
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 importSources = ref<Array<{ value: string; label: string }>>([]);
|
||||||
const importSourcesReady = ref(false);
|
const importSourcesReady = ref(false);
|
||||||
const importSourcesFailed = ref(false);
|
const importSourcesFailed = ref(false);
|
||||||
@@ -142,7 +140,6 @@ async function loadTreasuryAccounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
await loadCustomerGroups().catch(() => undefined);
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
|
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
|
||||||
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
|
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
|
||||||
@@ -362,7 +359,6 @@ const typesForActive = computed(() => {
|
|||||||
:key="active"
|
:key="active"
|
||||||
:section="editorSection"
|
:section="editorSection"
|
||||||
:model-value="sectionValue(editorSection)"
|
:model-value="sectionValue(editorSection)"
|
||||||
:groups="customerGroups"
|
|
||||||
:stages="stagesForActive"
|
:stages="stagesForActive"
|
||||||
:pipelines="pipelines"
|
:pipelines="pipelines"
|
||||||
:boards="boardsForActive"
|
:boards="boardsForActive"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const props = withDefaults(
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
icon?: 'calendar' | 'clock' | 'bell';
|
icon?: 'calendar' | 'clock' | 'bell';
|
||||||
|
/** اگر داده شود، متن باکس بهجای تاریخ خام همین مقدار است. */
|
||||||
|
displayText?: string;
|
||||||
|
/** نمایی که با باز شدن پنل دیده میشود. */
|
||||||
|
startView?: 'days' | 'months' | 'years';
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
mode: 'date',
|
mode: 'date',
|
||||||
@@ -37,6 +41,8 @@ const props = withDefaults(
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
|
displayText: '',
|
||||||
|
startView: 'days',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const display = computed(() => {
|
const display = computed(() => {
|
||||||
|
if (props.displayText) return props.displayText;
|
||||||
if (props.mode === 'time') {
|
if (props.mode === 'time') {
|
||||||
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
|
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
|
||||||
if (!match) return '';
|
if (!match) return '';
|
||||||
@@ -353,7 +360,7 @@ function place() {
|
|||||||
|
|
||||||
function liftAboveOverlays() {
|
function liftAboveOverlays() {
|
||||||
let z = 120;
|
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);
|
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||||
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
|
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
|
||||||
}
|
}
|
||||||
@@ -368,7 +375,7 @@ function onWindowChange() {
|
|||||||
async function show() {
|
async function show() {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
syncDraftFrom(parsed.value);
|
syncDraftFrom(parsed.value);
|
||||||
calView.value = 'days';
|
calView.value = props.startView;
|
||||||
clockFace.value = 'hour';
|
clockFace.value = 'hour';
|
||||||
syncViewport();
|
syncViewport();
|
||||||
liftAboveOverlays();
|
liftAboveOverlays();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const props = withDefaults(
|
|||||||
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
||||||
asNumber?: boolean;
|
asNumber?: boolean;
|
||||||
dir?: 'ltr' | 'rtl';
|
dir?: 'ltr' | 'rtl';
|
||||||
|
addonAfter?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
required: false,
|
required: false,
|
||||||
@@ -30,6 +31,7 @@ const props = withDefaults(
|
|||||||
decimals: 2,
|
decimals: 2,
|
||||||
asNumber: false,
|
asNumber: false,
|
||||||
dir: 'ltr',
|
dir: 'ltr',
|
||||||
|
addonAfter: '',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:value="text"
|
:value="text"
|
||||||
|
:addon-after="addonAfter || undefined"
|
||||||
style="width: 100%; font-variant-numeric: tabular-nums"
|
style="width: 100%; font-variant-numeric: tabular-nums"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="onBlur"
|
@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,10 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
import type {
|
||||||
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
|
OutboundSmsHistoryResponse,
|
||||||
|
OutboundSmsLogDto,
|
||||||
|
PersonAssetsDto,
|
||||||
|
SmsTemplateDto,
|
||||||
|
} from '@mento/shared';
|
||||||
|
import {
|
||||||
|
MESSAGE_AUTOMATION_FIELDS,
|
||||||
|
SMS_TEMPLATE_BODY_MAX,
|
||||||
|
SMS_TEMPLATE_MAX,
|
||||||
|
SMS_TEMPLATE_NAME_MAX,
|
||||||
|
countSmsParts,
|
||||||
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
|
import { formatTahesabBedBes } from '@/lib/tahesab';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { UiButton, UiModal } from '@/components/ui';
|
import { UiButton, UiModal } from '@/components/ui';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -25,7 +38,7 @@ const props = withDefaults(
|
|||||||
recordTitle: '',
|
recordTitle: '',
|
||||||
company: '',
|
company: '',
|
||||||
title: 'ارسال پیامک',
|
title: 'ارسال پیامک',
|
||||||
defaultText: 'سلام {نام} عزیز',
|
defaultText: '',
|
||||||
nest: false,
|
nest: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -35,18 +48,73 @@ const emit = defineEmits<{
|
|||||||
sent: [];
|
sent: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
type Panel = 'none' | 'fields' | 'templates';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
const text = ref('');
|
const text = ref('');
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const done = ref('');
|
const done = ref('');
|
||||||
const fieldsOpen = ref(false);
|
const notice = ref('');
|
||||||
|
const panel = ref<Panel>('none');
|
||||||
|
const savingTemplate = ref(false);
|
||||||
|
const templateName = ref('');
|
||||||
|
const templateBody = ref('');
|
||||||
const bodyEl = ref<HTMLTextAreaElement | null>(null);
|
const bodyEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const templateBodyEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
const history = ref<OutboundSmsLogDto[]>([]);
|
const history = ref<OutboundSmsLogDto[]>([]);
|
||||||
const historyLoading = ref(false);
|
const historyLoading = ref(false);
|
||||||
|
const templates = ref<SmsTemplateDto[]>([]);
|
||||||
|
const templatesLoading = ref(false);
|
||||||
|
const fromOrg = ref('');
|
||||||
|
const rialBalanceText = ref('');
|
||||||
|
const metalBalanceText = ref('');
|
||||||
|
|
||||||
|
const METAL_FIELD_LABELS: Record<string, string> = {
|
||||||
|
MandeyeVazni: 'طلا',
|
||||||
|
MandeyeVazniNoghre: 'نقره',
|
||||||
|
MandeyeKolBeTALA: 'کل به طلا',
|
||||||
|
MandeMenhayeSeke: 'منهای سکه',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMetalMandehKey(key: string) {
|
||||||
|
if (METAL_FIELD_LABELS[key]) return true;
|
||||||
|
if (key.startsWith('Seke_')) return true;
|
||||||
|
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metalFieldLabel(key: string) {
|
||||||
|
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
|
||||||
|
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAssetsToVars(assets: PersonAssetsDto) {
|
||||||
|
const fields = assets.mandehFields ?? [];
|
||||||
|
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
|
||||||
|
const rialRaw = mali?.value ?? assets.rialBalance;
|
||||||
|
rialBalanceText.value =
|
||||||
|
rialRaw == null || String(rialRaw).trim() === ''
|
||||||
|
? ''
|
||||||
|
: formatTahesabBedBes(rialRaw);
|
||||||
|
const metalParts = fields
|
||||||
|
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
|
||||||
|
.map((f) => `${metalFieldLabel(f.key)} ${formatTahesabBedBes(f.value)}`.trim());
|
||||||
|
metalBalanceText.value =
|
||||||
|
metalParts.join('، ') ||
|
||||||
|
(assets.goldBalance != null ? `طلا ${formatTahesabBedBes(assets.goldBalance)}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
const preview = computed(() => fill(text.value));
|
const preview = computed(() => fill(text.value));
|
||||||
const count = computed(() => text.value.length);
|
const count = computed(() => text.value.length);
|
||||||
const parts = computed(() => countSmsParts(text.value));
|
const parts = computed(() => countSmsParts(text.value));
|
||||||
|
const canSaveTemplate = computed(
|
||||||
|
() =>
|
||||||
|
templateName.value.trim().length >= 1 &&
|
||||||
|
templateBody.value.trim().length >= 2 &&
|
||||||
|
templates.value.length < SMS_TEMPLATE_MAX &&
|
||||||
|
!savingTemplate.value,
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
@@ -55,12 +123,43 @@ watch(
|
|||||||
text.value = props.defaultText;
|
text.value = props.defaultText;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
done.value = '';
|
done.value = '';
|
||||||
fieldsOpen.value = false;
|
notice.value = '';
|
||||||
|
panel.value = 'none';
|
||||||
|
templateName.value = '';
|
||||||
|
templateBody.value = '';
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
|
fromOrg.value = auth.user?.organizationName?.trim() || '';
|
||||||
|
rialBalanceText.value = '';
|
||||||
|
metalBalanceText.value = '';
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
|
void loadTemplates();
|
||||||
|
void loadContextVars();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function loadContextVars() {
|
||||||
|
if (!fromOrg.value) {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ name?: string }>('/organizations/current');
|
||||||
|
if (data?.name?.trim()) fromOrg.value = data.name.trim();
|
||||||
|
} catch {
|
||||||
|
/* نام سازمان از auth کافی است */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const personMatch = props.endpoint.match(/\/people\/([^/]+)\/(?:sms|birthday-sms)/);
|
||||||
|
if (!personMatch?.[1]) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<PersonAssetsDto>(
|
||||||
|
`/integrations/tahesab/people/${personMatch[1]}/assets`,
|
||||||
|
{ params: { refresh: '0' } },
|
||||||
|
);
|
||||||
|
applyAssetsToVars(data);
|
||||||
|
} catch {
|
||||||
|
rialBalanceText.value = '';
|
||||||
|
metalBalanceText.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
history.value = [];
|
history.value = [];
|
||||||
if (!props.historyEndpoint) return;
|
if (!props.historyEndpoint) return;
|
||||||
@@ -77,41 +176,158 @@ 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 (e) {
|
||||||
|
templates.value = [];
|
||||||
|
error.value = apiMessage(e, 'بارگذاری پیامکهای آماده ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
templatesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FIELD_HINT: Record<string, string> = {
|
||||||
|
عنوان: '〔عنوان ثبت نشده〕',
|
||||||
|
title: '〔عنوان ثبت نشده〕',
|
||||||
|
شرکت: '〔شرکت مشتری ثبت نشده〕',
|
||||||
|
company: '〔شرکت مشتری ثبت نشده〕',
|
||||||
|
نام: '〔نام ثبت نشده〕',
|
||||||
|
name: '〔نام ثبت نشده〕',
|
||||||
|
شماره: '〔شماره ثبت نشده〕',
|
||||||
|
phone: '〔شماره ثبت نشده〕',
|
||||||
|
'از طرف': '〔سازمان ثبت نشده〕',
|
||||||
|
from: '〔سازمان ثبت نشده〕',
|
||||||
|
'مانده ریالی': '〔مانده ریالی نیست〕',
|
||||||
|
rialBalance: '〔مانده ریالی نیست〕',
|
||||||
|
'مانده فلزی': '〔مانده فلزی نیست〕',
|
||||||
|
metalBalance: '〔مانده فلزی نیست〕',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTemplateKey(key: string) {
|
||||||
|
return String(key ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
|
||||||
|
.replace(/ي/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پیشنمایش؛ اگر مقدار خالی باشد نشانه میگذارد تا با فیلد خالی قاطی نشود. */
|
||||||
function fill(source: string) {
|
function fill(source: string) {
|
||||||
const map: Record<string, string> = {
|
const from = fromOrg.value.trim();
|
||||||
نام: props.name,
|
const rial = rialBalanceText.value.trim();
|
||||||
name: props.name,
|
const metal = metalBalanceText.value.trim();
|
||||||
شماره: props.phone,
|
const entries: Array<[string, string]> = [
|
||||||
phone: props.phone,
|
['نام', props.name.trim()],
|
||||||
تاریخ: formatJalaliDay(new Date()),
|
['name', props.name.trim()],
|
||||||
date: formatJalaliDay(new Date()),
|
['شماره', props.phone.trim()],
|
||||||
عنوان: props.recordTitle || props.name,
|
['phone', props.phone.trim()],
|
||||||
title: props.recordTitle || props.name,
|
['تاریخ', formatJalaliDay(new Date())],
|
||||||
شرکت: props.company,
|
['date', formatJalaliDay(new Date())],
|
||||||
company: props.company,
|
['عنوان', props.recordTitle.trim()],
|
||||||
};
|
['title', props.recordTitle.trim()],
|
||||||
return source.replace(/\{([^}]+)\}/g, (full, key: string) => {
|
['شرکت', props.company.trim()],
|
||||||
const next = map[key.trim()];
|
['company', props.company.trim()],
|
||||||
return next === undefined ? full : next;
|
['از طرف', from],
|
||||||
|
['ازطرف', from],
|
||||||
|
['from', from],
|
||||||
|
['مانده ریالی', rial],
|
||||||
|
['ماندهریالی', rial],
|
||||||
|
['rialBalance', rial],
|
||||||
|
['مانده فلزی', metal],
|
||||||
|
['ماندهفلزی', metal],
|
||||||
|
['metalBalance', metal],
|
||||||
|
];
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
const hints: Record<string, string> = {};
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const norm = normalizeTemplateKey(key);
|
||||||
|
map[norm] = value;
|
||||||
|
if (EMPTY_FIELD_HINT[key]) hints[norm] = EMPTY_FIELD_HINT[key]!;
|
||||||
|
}
|
||||||
|
return source.replace(/\{([^}]+)\}/g, (full, rawKey: string) => {
|
||||||
|
const key = normalizeTemplateKey(rawKey);
|
||||||
|
if (!(key in map)) return full;
|
||||||
|
const next = map[key] ?? '';
|
||||||
|
if (next) return next;
|
||||||
|
return hints[key] ?? full;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertField(token: string) {
|
function insertInto(
|
||||||
const el = bodyEl.value;
|
target: 'compose' | 'draft',
|
||||||
const current = text.value;
|
token: string,
|
||||||
|
) {
|
||||||
|
const el = target === 'compose' ? bodyEl.value : templateBodyEl.value;
|
||||||
|
const current = target === 'compose' ? text.value : templateBody.value;
|
||||||
|
const setValue = (next: string) => {
|
||||||
|
if (target === 'compose') text.value = next;
|
||||||
|
else templateBody.value = next;
|
||||||
|
};
|
||||||
if (!el) {
|
if (!el) {
|
||||||
text.value = `${current}${token}`;
|
setValue(`${current}${token}`);
|
||||||
} else {
|
return;
|
||||||
const start = el.selectionStart ?? current.length;
|
}
|
||||||
const end = el.selectionEnd ?? start;
|
const start = el.selectionStart ?? current.length;
|
||||||
text.value = `${current.slice(0, start)}${token}${current.slice(end)}`;
|
const end = el.selectionEnd ?? start;
|
||||||
const caret = start + token.length;
|
setValue(`${current.slice(0, start)}${token}${current.slice(end)}`);
|
||||||
requestAnimationFrame(() => {
|
const caret = start + token.length;
|
||||||
el.focus();
|
void nextTick(() => {
|
||||||
el.setSelectionRange(caret, caret);
|
el.focus();
|
||||||
});
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel(next: Panel) {
|
||||||
|
panel.value = panel.value === next ? 'none' : next;
|
||||||
|
notice.value = '';
|
||||||
|
if (panel.value === 'templates') {
|
||||||
|
error.value = '';
|
||||||
|
void loadTemplates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(item: SmsTemplateDto) {
|
||||||
|
text.value = item.body;
|
||||||
|
error.value = '';
|
||||||
|
notice.value = `قالب «${item.name}» در متن پیام قرار گرفت.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
const name = templateName.value.trim().slice(0, SMS_TEMPLATE_NAME_MAX);
|
||||||
|
const body = templateBody.value.trim().slice(0, SMS_TEMPLATE_BODY_MAX);
|
||||||
|
if (!canSaveTemplate.value || !name || body.length < 2) return;
|
||||||
|
savingTemplate.value = true;
|
||||||
|
error.value = '';
|
||||||
|
notice.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<SmsTemplateDto[]>('/sms-templates', { name, body });
|
||||||
|
templates.value = Array.isArray(data) ? data : [];
|
||||||
|
templateName.value = '';
|
||||||
|
templateBody.value = '';
|
||||||
|
notice.value = `پیامک آماده «${name}» ذخیره شد.`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiMessage(e, 'ذخیره پیامک آماده ناموفق بود.');
|
||||||
|
} finally {
|
||||||
|
savingTemplate.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTemplate(item: SmsTemplateDto) {
|
||||||
|
if (!confirm(`پیامک آماده «${item.name}» حذف شود؟`)) return;
|
||||||
|
error.value = '';
|
||||||
|
notice.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await api.delete<SmsTemplateDto[]>(`/sms-templates/${item.id}`);
|
||||||
|
templates.value = Array.isArray(data) ? data : [];
|
||||||
|
notice.value = `«${item.name}» حذف شد.`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiMessage(e, 'حذف پیامک آماده ناموفق بود.');
|
||||||
}
|
}
|
||||||
fieldsOpen.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiMessage(e: unknown, fallback: string) {
|
function apiMessage(e: unknown, fallback: string) {
|
||||||
@@ -122,17 +338,28 @@ function apiMessage(e: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send(force = false) {
|
||||||
if (!props.endpoint || busy.value || done.value) return;
|
if (!props.endpoint || busy.value || done.value) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
notice.value = '';
|
||||||
try {
|
try {
|
||||||
await api.post(props.endpoint, { text: text.value });
|
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
|
||||||
done.value = 'پیامک ارسال شد.';
|
done.value = 'پیامک ارسال شد.';
|
||||||
emit('sent');
|
emit('sent');
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -152,22 +379,141 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<button type="button" class="pill" @click="fieldsOpen = !fieldsOpen">فیلدهای پویا</button>
|
<div class="tool-row">
|
||||||
<div v-if="fieldsOpen" class="fields">
|
|
||||||
<button
|
<button
|
||||||
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
|
||||||
:key="item.token"
|
|
||||||
type="button"
|
type="button"
|
||||||
@click="insertField(item.token)"
|
class="pill"
|
||||||
|
:class="{ on: panel === 'fields' }"
|
||||||
|
@click="openPanel('fields')"
|
||||||
>
|
>
|
||||||
<code>{{ item.token }}</code>
|
فیلدهای پویا
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
:class="{ on: panel === 'templates' }"
|
||||||
|
@click="openPanel('templates')"
|
||||||
|
>
|
||||||
|
ساخت پیامک آماده
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="panel === 'fields'" class="panel">
|
||||||
|
<p class="panel-hint">روی فیلد بزنید تا داخل متن پیام درج شود.</p>
|
||||||
|
<div class="field-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in MESSAGE_AUTOMATION_FIELDS"
|
||||||
|
:key="item.token"
|
||||||
|
type="button"
|
||||||
|
@click="insertInto('compose', item.token)"
|
||||||
|
>
|
||||||
|
<code>{{ item.token }}</code>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="panel === 'templates'" class="panel">
|
||||||
|
<div class="draft">
|
||||||
|
<strong>ساخت پیامک آماده جدید</strong>
|
||||||
|
<p class="panel-hint">این متن جدا از پیام ارسالی است؛ بعد از ذخیره، عنوانش بالا برای استفاده سریع میآید.</p>
|
||||||
|
<input
|
||||||
|
v-model="templateName"
|
||||||
|
type="text"
|
||||||
|
:maxlength="SMS_TEMPLATE_NAME_MAX"
|
||||||
|
placeholder="نام قالب (مثلاً پیگیری جلسه)"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
ref="templateBodyEl"
|
||||||
|
v-model="templateBody"
|
||||||
|
rows="3"
|
||||||
|
:maxlength="SMS_TEMPLATE_BODY_MAX"
|
||||||
|
placeholder="متن قالب، مثلاً: سلام {نام} عزیز…"
|
||||||
|
/>
|
||||||
|
<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="insertInto('draft', item.token)"
|
||||||
|
>
|
||||||
|
<code>{{ item.token }}</code>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="save-btn"
|
||||||
|
:disabled="!canSaveTemplate"
|
||||||
|
@click="saveTemplate"
|
||||||
|
>
|
||||||
|
{{ savingTemplate ? 'در حال ذخیره…' : 'ذخیره قالب' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="templates.length >= SMS_TEMPLATE_MAX" class="muted">
|
||||||
|
سقف {{ toFaDigits(SMS_TEMPLATE_MAX) }} قالب پر شده است.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="templates.length" class="manage-list">
|
||||||
|
<p class="panel-hint">حذف قالبهای ذخیرهشده:</p>
|
||||||
|
<ul class="template-list">
|
||||||
|
<li v-for="item in templates" :key="`del-${item.id}`">
|
||||||
|
<span class="pick-name">{{ item.name }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="del"
|
||||||
|
:aria-label="`حذف ${item.name}`"
|
||||||
|
@click="removeTemplate(item)"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea ref="bodyEl" v-model="text" rows="4" maxlength="500" placeholder="سلام {نام} عزیز" />
|
<div class="compose-layout">
|
||||||
<p class="count">{{ toFaDigits(count) }} نویسه · {{ toFaDigits(parts) }} پیامک</p>
|
<div class="compose-main">
|
||||||
|
<label class="compose-label" for="sms-compose-body">متن پیام ارسالی</label>
|
||||||
|
<textarea
|
||||||
|
id="sms-compose-body"
|
||||||
|
ref="bodyEl"
|
||||||
|
v-model="text"
|
||||||
|
rows="5"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="متن پیام را بنویسید یا از ستون قالبها انتخاب کنید"
|
||||||
|
/>
|
||||||
|
<p class="count">{{ toFaDigits(count) }} نویسه · {{ toFaDigits(parts) }} پیامک</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="quick-col" aria-label="انتخاب سریع پیامک آماده">
|
||||||
|
<strong class="quick-col-title">قالبها</strong>
|
||||||
|
<div v-if="templatesLoading" class="muted tiny">در حال بارگذاری…</div>
|
||||||
|
<div
|
||||||
|
v-else-if="templates.length"
|
||||||
|
class="quick-templates"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in templates"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="tpl-chip"
|
||||||
|
role="listitem"
|
||||||
|
:title="item.body"
|
||||||
|
@click="applyTemplate(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="muted tiny quick-empty">
|
||||||
|
هنوز قالبی نیست. از «ساخت پیامک آماده» بسازید.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<span>پیشنمایش</span>
|
<span>پیشنمایش</span>
|
||||||
@@ -195,6 +541,7 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="err">{{ error }}</p>
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
<p v-else-if="notice" class="ok">{{ notice }}</p>
|
||||||
<p v-if="done" class="ok">{{ done }}</p>
|
<p v-if="done" class="ok">{{ done }}</p>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -234,66 +581,279 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tools {
|
.tools {
|
||||||
position: relative;
|
margin-bottom: 0.55rem;
|
||||||
margin-bottom: 0.45rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill,
|
.tool-row {
|
||||||
.fields button {
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
padding: 0.28rem 0.7rem;
|
padding: 0.28rem 0.7rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields {
|
.pill.on {
|
||||||
position: absolute;
|
background: var(--accent-deep);
|
||||||
z-index: 2;
|
color: #fff;
|
||||||
top: calc(100% + 0.3rem);
|
border-color: var(--accent-deep);
|
||||||
inset-inline: 0;
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 0.45rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.45rem;
|
||||||
max-width: 100%;
|
padding: 0.55rem;
|
||||||
padding: 0.4rem;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e7eef2;
|
border: 1px solid #e7eef2;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fields button {
|
.panel-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
.fields code {
|
|
||||||
color: var(--accent-deep);
|
color: var(--accent-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.field-grid code {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-height: 9.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 10.5rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 11.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #e7eef2;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-col-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-templates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding-inline-end: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-chip {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--accent-deep);
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: start;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-chip:hover {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-empty {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.compose-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-col {
|
||||||
|
max-height: 8.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-list {
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list .pick-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #0f172a;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft strong {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft input,
|
||||||
|
.draft textarea,
|
||||||
|
#sms-compose-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
padding: 0.7rem 0.8rem;
|
padding: 0.55rem 0.7rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
resize: vertical;
|
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draft textarea,
|
||||||
|
#sms-compose-body {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-btn {
|
||||||
|
justify-self: start;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.15rem 0 0.35rem;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
@@ -316,6 +876,7 @@ textarea {
|
|||||||
margin: 0.3rem 0 0;
|
margin: 0.3rem 0 0;
|
||||||
color: #134e4a;
|
color: #134e4a;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history {
|
.history {
|
||||||
|
|||||||
@@ -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;
|
tableKey: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
|
/** فقط آیکون تنظیمات (بدون متن «ستونها») */
|
||||||
|
iconOnly?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{ label: 'ستونها', align: 'start' },
|
{ label: 'ستونها', align: 'start', iconOnly: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
@@ -60,11 +62,18 @@ const placement = computed(() =>
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</template>
|
</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>
|
<template #icon><SettingOutlined /></template>
|
||||||
{{ label }}
|
<template v-if="!iconOnly">
|
||||||
<template v-if="hiddenCount">
|
{{ label }}
|
||||||
· {{ toFaDigits(hiddenCount) }} مخفی
|
<template v-if="hiddenCount">
|
||||||
|
· {{ toFaDigits(hiddenCount) }} مخفی
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
||||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||||
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
||||||
|
import { matchesSearch } from '@/lib/search';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
users: OrgUserDto[];
|
users: OrgUserDto[];
|
||||||
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
|
|||||||
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
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) {
|
async function setPriorArchive(userId: string, enabled: boolean) {
|
||||||
priorSavingId.value = userId;
|
priorSavingId.value = userId;
|
||||||
priorPick.value = '';
|
priorPick.value = '';
|
||||||
@@ -383,14 +407,22 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>تیمها</h3>
|
<header class="card-head">
|
||||||
|
<h3>تیمها</h3>
|
||||||
|
<UiInput
|
||||||
|
v-if="teams.length"
|
||||||
|
v-model="teamsQuery"
|
||||||
|
placeholder="جستجوی تیم یا عضو…"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
v-if="!loading && !teams.length"
|
v-if="!loading && !teams.length"
|
||||||
title="تیمی تعریف نشده"
|
title="تیمی تعریف نشده"
|
||||||
description="تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
description="تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||||
/>
|
/>
|
||||||
|
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
|
||||||
<div v-else class="team-list">
|
<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>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ t.name }}</strong>
|
<strong>{{ t.name }}</strong>
|
||||||
@@ -504,7 +536,14 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>محدوده داده</h3>
|
<header class="card-head">
|
||||||
|
<h3>محدوده داده</h3>
|
||||||
|
<UiInput
|
||||||
|
v-if="editableUsers.length"
|
||||||
|
v-model="accessQuery"
|
||||||
|
placeholder="جستجوی کاربر…"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
||||||
جدا بگذارید.
|
جدا بگذارید.
|
||||||
@@ -514,8 +553,9 @@ onMounted(() => {
|
|||||||
title="کاربر قابل تنظیم نیست"
|
title="کاربر قابل تنظیم نیست"
|
||||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||||
/>
|
/>
|
||||||
|
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
|
||||||
<div v-else class="vis-table">
|
<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">
|
<div class="vis-row">
|
||||||
<span>
|
<span>
|
||||||
{{ u.fullName }}
|
{{ u.fullName }}
|
||||||
@@ -567,6 +607,22 @@ onMounted(() => {
|
|||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
font-size: 1rem;
|
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 {
|
.prior-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -722,5 +778,35 @@ select {
|
|||||||
.prior-picker {
|
.prior-picker {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Select as ASelect } from 'ant-design-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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string[];
|
modelValue: string[];
|
||||||
users: PickUser[];
|
users: PickUser[];
|
||||||
|
/** انتخاب یک نقش، همهٔ اعضایش را مسئول میکند */
|
||||||
|
roles?: AssigneeGroup[];
|
||||||
|
/** انتخاب یک تیم، همهٔ اعضایش را مسئول میکند */
|
||||||
|
teams?: AssigneeGroup[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
||||||
min?: number;
|
min?: number;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
|
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمعکردن برچسبها وقتی تعداد زیاد است */
|
||||||
|
selectAll?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
|
{
|
||||||
|
roles: () => [],
|
||||||
|
teams: () => [],
|
||||||
|
disabled: false,
|
||||||
|
min: 0,
|
||||||
|
addLabel: 'افزودن',
|
||||||
|
emptyLabel: 'کسی انتخاب نشده',
|
||||||
|
selectAll: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,32 +39,208 @@ const emit = defineEmits<{
|
|||||||
change: [ids: string[]];
|
change: [ids: string[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const options = computed(() =>
|
const pinned = ref(new Set<string>());
|
||||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
|
||||||
|
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 selectValue = computed(() => {
|
||||||
const ids = Array.isArray(value) ? value.map(String) : [];
|
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) {
|
if (ids.length < props.min && props.modelValue.length >= props.min) {
|
||||||
emit('update:modelValue', [...props.modelValue]);
|
emit('update:modelValue', [...props.modelValue]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('update:modelValue', ids);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-select
|
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
||||||
mode="multiple"
|
<a-select
|
||||||
allow-clear
|
mode="multiple"
|
||||||
show-search
|
allow-clear
|
||||||
option-filter-prop="label"
|
show-search
|
||||||
:value="modelValue"
|
:value="selectValue"
|
||||||
:options="options"
|
:options="options"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:placeholder="emptyLabel"
|
:placeholder="emptyLabel"
|
||||||
style="width: 100%"
|
:filter-option="filterOption"
|
||||||
@update:value="onUpdate"
|
: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>
|
</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';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { activityTypeView } from '@/lib/activity-types';
|
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 PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||||
|
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_BOARD_COPY,
|
ACTIVITY_BOARD_COPY,
|
||||||
initialsFromTitle,
|
initialsFromTitle,
|
||||||
@@ -41,6 +42,7 @@ const emit = defineEmits<{
|
|||||||
stagesChanged: [];
|
stagesChanged: [];
|
||||||
add: [stageId: string];
|
add: [stageId: string];
|
||||||
error: [message: string];
|
error: [message: string];
|
||||||
|
notice: [message: string];
|
||||||
menuDeal: [activity: ActivityDto, stageId?: string];
|
menuDeal: [activity: ActivityDto, stageId?: string];
|
||||||
toggleSelect: [activityId: string];
|
toggleSelect: [activityId: string];
|
||||||
bulkDelete: [];
|
bulkDelete: [];
|
||||||
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
|
|||||||
const overlayDeal = ref<PipelineDeal | null>(null);
|
const overlayDeal = ref<PipelineDeal | null>(null);
|
||||||
const overlayAccent = ref<PipelineAccent>('green');
|
const overlayAccent = ref<PipelineAccent>('green');
|
||||||
const overlayPos = ref({ x: 0, y: 0 });
|
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 moving = ref(false);
|
||||||
const stageBusy = ref(false);
|
const stageBusy = ref(false);
|
||||||
const localOrder = ref<string[] | null>(null);
|
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> = {
|
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
|
||||||
open: undefined,
|
open: undefined,
|
||||||
@@ -88,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
|
|||||||
return fallback;
|
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 {
|
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||||
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
|
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
|
||||||
const assigneeNames = item.assignees?.length
|
const assigneeNames = item.assignees?.length
|
||||||
@@ -112,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
||||||
amount: null,
|
amount: null,
|
||||||
date: dateText(item),
|
date:
|
||||||
|
item.status === 'done'
|
||||||
|
? 'انجام شده'
|
||||||
|
: item.dueAt
|
||||||
|
? formatRelativeDayLabel(item.dueAt)
|
||||||
|
: 'بدون موعد',
|
||||||
|
dateIso: item.dueAt ?? undefined,
|
||||||
initials: initialsFromTitle(party || item.title),
|
initials: initialsFromTitle(party || item.title),
|
||||||
stageId,
|
stageId,
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
@@ -121,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
|||||||
ownerId: item.assignee?.id ?? null,
|
ownerId: item.assignee?.id ?? null,
|
||||||
assigneeIds: (item.assignees ?? []).map((a) => a.id),
|
assigneeIds: (item.assignees ?? []).map((a) => a.id),
|
||||||
priority: item.priority,
|
priority: item.priority,
|
||||||
|
activityStatus: item.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +259,12 @@ function onMenuActivity(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||||
draggingId.value = deal.id;
|
draggingColumnId.value = null;
|
||||||
overlayDeal.value = deal;
|
// قفل/صف مانده از درخواست قبلی را باز کن
|
||||||
const sid = findStageOf(deal.id);
|
if (moving.value) resetMoveLock();
|
||||||
overlayAccent.value =
|
|
||||||
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
|
dragSessionId = deal.id;
|
||||||
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
|
// setData / setDragImage باید همزمان با dragstart باشند
|
||||||
event.dataTransfer?.setData('text/plain', deal.id);
|
event.dataTransfer?.setData('text/plain', deal.id);
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -235,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
|||||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
event.dataTransfer.setDragImage(img, 0, 0);
|
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() {
|
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;
|
draggingId.value = null;
|
||||||
dropStageId.value = null;
|
|
||||||
overlayDeal.value = null;
|
overlayDeal.value = null;
|
||||||
|
draggingColumnId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onColumnDragEnd() {
|
||||||
|
draggingColumnId.value = null;
|
||||||
|
dropStageId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(stageId: string, event: DragEvent) {
|
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) {
|
async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||||
const fromStageId = findStageOf(activityId);
|
const fromStageId = findStageOf(activityId);
|
||||||
if (!fromStageId) return;
|
if (!fromStageId) return;
|
||||||
const from = columns.value[fromStageId]!;
|
const from = columns.value[fromStageId];
|
||||||
const to = columns.value[toStageId];
|
const to = columns.value[toStageId];
|
||||||
if (!to) return;
|
if (!from || !to) return;
|
||||||
const fromIndex = from.items.findIndex((i) => i.id === activityId);
|
const fromIndex = from.items.findIndex((i) => i.id === activityId);
|
||||||
const item = from.items[fromIndex];
|
const item = from.items[fromIndex];
|
||||||
if (!item) return;
|
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 same = fromStageId === toStageId;
|
||||||
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
|
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
|
||||||
if (same && index == null) return;
|
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;
|
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||||
if (same && insertAt === fromIndex) return;
|
if (same && insertAt === fromIndex) return;
|
||||||
@@ -287,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
moving.value = true;
|
moving.value = true;
|
||||||
|
armMoveWatchdog();
|
||||||
try {
|
try {
|
||||||
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
||||||
stageId: toStageId,
|
stageId: toStageId,
|
||||||
position: insertAt,
|
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;
|
const column = same ? from : to;
|
||||||
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
||||||
emit('changed');
|
emit('changed');
|
||||||
@@ -304,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
|
|||||||
}
|
}
|
||||||
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(moveWatchdog);
|
||||||
moving.value = false;
|
moving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
|
const transfer = event.dataTransfer?.getData('text/plain') || '';
|
||||||
dropStageId.value = null;
|
const cardId = dragSessionId || draggingId.value;
|
||||||
draggingId.value = null;
|
const columnId = draggingColumnId.value;
|
||||||
overlayDeal.value = null;
|
clearDragState();
|
||||||
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
|
||||||
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت بهاشتباه reorder ستون نشود
|
||||||
draggingColumnId.value = null;
|
const activityId =
|
||||||
if (fromId) await reorderColumn(fromId, stageId);
|
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
|
||||||
|
if (activityId) {
|
||||||
|
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
|
||||||
|
void enqueueMove(() => moveCard(activityId, stageId, index));
|
||||||
return;
|
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[]) {
|
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> = {
|
const query: Record<string, string> = {
|
||||||
from: 'desk',
|
from: 'desk',
|
||||||
nav: crypto.randomUUID(),
|
nav: crypto.randomUUID(),
|
||||||
@@ -418,13 +511,38 @@ function openActivity(id: string) {
|
|||||||
if (source.typeDefId) query.typeDefId = source.typeDefId;
|
if (source.typeDefId) query.typeDefId = source.typeDefId;
|
||||||
else if (source.type) query.type = source.type;
|
else if (source.type) query.type = source.type;
|
||||||
if (source.status) query.status = source.status;
|
if (source.status) query.status = source.status;
|
||||||
|
if (source.priority) query.priority = source.priority;
|
||||||
if (source.assigneeId) query.assigneeId = source.assigneeId;
|
if (source.assigneeId) query.assigneeId = source.assigneeId;
|
||||||
if (source.title) query.title = source.title;
|
if (source.title) query.title = source.title;
|
||||||
if (source.customer) query.customer = source.customer;
|
if (source.customer) query.customer = source.customer;
|
||||||
if (source.due) query.due = source.due;
|
if (source.due) query.due = source.due;
|
||||||
const columnId = findStageOf(id);
|
const columnId = findStageOf(id);
|
||||||
if (columnId) query.stage = columnId;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -440,23 +558,32 @@ function openActivity(id: string) {
|
|||||||
:copy="ACTIVITY_BOARD_COPY"
|
:copy="ACTIVITY_BOARD_COPY"
|
||||||
:select-mode="selectMode"
|
:select-mode="selectMode"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
|
:peek-id="peekId"
|
||||||
|
:preview-id="quickId"
|
||||||
@drag-start="onDragStart"
|
@drag-start="onDragStart"
|
||||||
@drag-end="onDragEnd"
|
@drag-end="onDragEnd"
|
||||||
@drag-over="onDragOver"
|
@drag-over="onDragOver"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
@add-deal="emit('add', $event)"
|
@add-deal="emit('add', $event)"
|
||||||
@open-deal="openActivity"
|
@open-deal="openActivity"
|
||||||
|
@detail-deal="openFullActivity"
|
||||||
@menu-deal="onMenuActivity"
|
@menu-deal="onMenuActivity"
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@bulk-delete="emit('bulkDelete')"
|
@bulk-delete="emit('bulkDelete')"
|
||||||
@rename-stage="onRenameStage"
|
@rename-stage="onRenameStage"
|
||||||
@delete-stage="onDeleteStage"
|
@delete-stage="onDeleteStage"
|
||||||
@move-stage="onMoveStage"
|
@move-stage="onMoveStage"
|
||||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
@column-drag-start="onColumnDragStart"
|
||||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
@column-drag-end="onColumnDragEnd"
|
||||||
@add-stage="onAddStage"
|
@add-stage="onAddStage"
|
||||||
@load-more="loadMore"
|
@load-more="loadMore"
|
||||||
/>
|
/>
|
||||||
|
<ActivityQuickView
|
||||||
|
:activity-id="quickId"
|
||||||
|
@close="closeQuick"
|
||||||
|
@changed="loadAll()"
|
||||||
|
@full="openFullActivity"
|
||||||
|
/>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<DealCardOverlay
|
<DealCardOverlay
|
||||||
v-if="overlayDeal && draggingId"
|
v-if="overlayDeal && draggingId"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const props = defineProps<{
|
|||||||
activity: ActivityDto;
|
activity: ActivityDto;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
||||||
|
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||||
|
overlayZIndex?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div class="layer" role="presentation">
|
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||||
<div class="backdrop" aria-hidden="true" />
|
<div class="backdrop" aria-hidden="true" />
|
||||||
<section
|
<section
|
||||||
class="sheet"
|
class="sheet"
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
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<{
|
const props = defineProps<{
|
||||||
/** یک ستون، یا پنل کامل بالای صفحه */
|
/** یک ستون، یا پنل کامل بالای صفحه */
|
||||||
@@ -10,11 +17,13 @@ const props = defineProps<{
|
|||||||
customer: string;
|
customer: string;
|
||||||
due: '' | 'set' | 'unset';
|
due: '' | 'set' | 'unset';
|
||||||
typeKey: string;
|
typeKey: string;
|
||||||
|
priorityKey: string;
|
||||||
statusKey: string;
|
statusKey: string;
|
||||||
assigneeId: string;
|
assigneeId: string;
|
||||||
showAssignee: boolean;
|
showAssignee: boolean;
|
||||||
users: Array<{ id: string; fullName: string }>;
|
users: Array<{ id: string; fullName: string }>;
|
||||||
types: Array<{ key: string; label: string }>;
|
types: Array<{ key: string; label: string }>;
|
||||||
|
priorities: Array<{ key: string; label: string; color?: string }>;
|
||||||
statuses: Array<{ key: string; label: string }>;
|
statuses: Array<{ key: string; label: string }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -23,6 +32,7 @@ const emit = defineEmits<{
|
|||||||
customer: [value: string];
|
customer: [value: string];
|
||||||
due: [value: '' | 'set' | 'unset'];
|
due: [value: '' | 'set' | 'unset'];
|
||||||
type: [value: string];
|
type: [value: string];
|
||||||
|
priority: [value: string];
|
||||||
status: [value: string];
|
status: [value: string];
|
||||||
assignee: [value: string];
|
assignee: [value: string];
|
||||||
clear: [];
|
clear: [];
|
||||||
@@ -86,6 +96,7 @@ const hasAny = computed(
|
|||||||
() =>
|
() =>
|
||||||
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
|
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
|
||||||
props.typeKey !== 'all' ||
|
props.typeKey !== 'all' ||
|
||||||
|
props.priorityKey !== 'all' ||
|
||||||
props.statusKey !== 'all',
|
props.statusKey !== 'all',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,6 +112,9 @@ const whoLabel = computed(
|
|||||||
const typeLabel = computed(
|
const typeLabel = computed(
|
||||||
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
|
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
|
||||||
);
|
);
|
||||||
|
const priorityLabel = computed(
|
||||||
|
() => props.priorities.find((item) => item.key === props.priorityKey)?.label || 'همه',
|
||||||
|
);
|
||||||
const statusLabel = computed(
|
const statusLabel = computed(
|
||||||
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
|
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
|
||||||
);
|
);
|
||||||
@@ -144,6 +158,11 @@ function pickType(next: string) {
|
|||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickPriority(next: string) {
|
||||||
|
emit('priority', props.priorityKey === next && next !== 'all' ? 'all' : next);
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
function pickStatus(next: string) {
|
function pickStatus(next: string) {
|
||||||
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
|
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
|
||||||
finish();
|
finish();
|
||||||
@@ -282,6 +301,36 @@ function pickAssignee(next: string) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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')">
|
<section v-if="shows('when')">
|
||||||
<button
|
<button
|
||||||
v-if="field === 'all'"
|
v-if="field === 'all'"
|
||||||
@@ -434,6 +483,9 @@ function pickAssignee(next: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pick button {
|
.pick button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.42rem 0.5rem;
|
padding: 0.42rem 0.5rem;
|
||||||
@@ -450,6 +502,13 @@ function pickAssignee(next: string) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prio-dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.seg {
|
.seg {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
dueAt?: string | null;
|
dueAt?: string | null;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||||
|
overlayZIndex?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<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" />
|
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
||||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
||||||
<div class="handle" aria-hidden="true" />
|
<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 {
|
import type {
|
||||||
ActivityFieldDef,
|
ActivityFieldDef,
|
||||||
ActivityFieldType,
|
ActivityFieldType,
|
||||||
|
ActivityPrioritySetting,
|
||||||
ActivityStageColor,
|
ActivityStageColor,
|
||||||
ActivityType,
|
ActivityType,
|
||||||
ActivityTypeDefDto,
|
ActivityTypeDefDto,
|
||||||
@@ -17,9 +18,11 @@ import {
|
|||||||
ACTIVITY_STAGE_COLORS,
|
ACTIVITY_STAGE_COLORS,
|
||||||
ACTIVITY_TYPE_ICONS,
|
ACTIVITY_TYPE_ICONS,
|
||||||
ACTIVITY_TYPE_LABELS,
|
ACTIVITY_TYPE_LABELS,
|
||||||
|
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_TYPE_ICON_LABELS,
|
ACTIVITY_TYPE_ICON_LABELS,
|
||||||
newFieldKey,
|
newFieldKey,
|
||||||
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
|
|||||||
const props = defineProps<{ open: boolean }>();
|
const props = defineProps<{ open: boolean }>();
|
||||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
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'];
|
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||||
|
|
||||||
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
||||||
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
|
|||||||
notice.value = '';
|
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(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
async (open) => {
|
async (open) => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
notice.value = '';
|
notice.value = '';
|
||||||
|
tab.value = 'types';
|
||||||
await load(true);
|
await load(true);
|
||||||
|
await prioritiesApi.load(true);
|
||||||
const first = types.value[0];
|
const first = types.value[0];
|
||||||
selectedId.value = null;
|
selectedId.value = null;
|
||||||
draft.value = null;
|
draft.value = null;
|
||||||
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
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');
|
emit('close');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -291,6 +371,28 @@ function close() {
|
|||||||
<template>
|
<template>
|
||||||
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
||||||
<div class="mgr">
|
<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">
|
<p class="hint">
|
||||||
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
||||||
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
||||||
@@ -479,6 +581,43 @@ function close() {
|
|||||||
</section>
|
</section>
|
||||||
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</UiModal>
|
</UiModal>
|
||||||
</template>
|
</template>
|
||||||
@@ -489,6 +628,78 @@ function close() {
|
|||||||
gap: 0.9rem;
|
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 {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #64748b;
|
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 { RouterLink } from 'vue-router';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
import { hasSectionPermission } from '@/lib/section-permissions';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import { UiModal } from '@/components/ui';
|
import { UiModal } from '@/components/ui';
|
||||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||||
@@ -25,9 +27,13 @@ type BirthdayMonth = {
|
|||||||
items: BirthdayItem[];
|
items: BirthdayItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
const data = ref<BirthdayMonth | null>(null);
|
const data = ref<BirthdayMonth | null>(null);
|
||||||
const listOpen = ref(false);
|
const listOpen = ref(false);
|
||||||
const smsTarget = ref<BirthdayItem | null>(null);
|
const smsTarget = ref<BirthdayItem | null>(null);
|
||||||
|
const canSeeCustomers = computed(() =>
|
||||||
|
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
|
||||||
|
);
|
||||||
|
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
const map = new Map<number, BirthdayItem[]>();
|
const map = new Map<number, BirthdayItem[]>();
|
||||||
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!canSeeCustomers.value) {
|
||||||
|
data.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
|
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
|
||||||
data.value = body.total > 0 ? body : null;
|
data.value = body.total > 0 ? body : null;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
FlagFilled,
|
FlagFilled,
|
||||||
|
LeftOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -18,10 +19,12 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
theme,
|
theme,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
|
||||||
|
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
|
||||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||||
import type { ActivityPriority } from '@mento/shared';
|
|
||||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||||
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -31,6 +34,9 @@ const props = withDefaults(
|
|||||||
copy?: PipelineUiCopy;
|
copy?: PipelineUiCopy;
|
||||||
selectMode?: boolean;
|
selectMode?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
peeking?: boolean;
|
||||||
|
/** پنل کناری همین کارت باز است؛ فلش را برمیگرداند. جدا از هایلایتِ بعد از بستن. */
|
||||||
|
previewing?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{ copy: () => DEAL_BOARD_COPY },
|
{ copy: () => DEAL_BOARD_COPY },
|
||||||
);
|
);
|
||||||
@@ -39,6 +45,7 @@ const emit = defineEmits<{
|
|||||||
dragStart: [deal: PipelineDeal, event: DragEvent];
|
dragStart: [deal: PipelineDeal, event: DragEvent];
|
||||||
dragEnd: [];
|
dragEnd: [];
|
||||||
open: [dealId: string];
|
open: [dealId: string];
|
||||||
|
detail: [dealId: string];
|
||||||
menu: [dealId: string];
|
menu: [dealId: string];
|
||||||
toggleSelect: [dealId: string];
|
toggleSelect: [dealId: string];
|
||||||
bulkDelete: [];
|
bulkDelete: [];
|
||||||
@@ -47,6 +54,11 @@ const emit = defineEmits<{
|
|||||||
const { Text: ATypographyText } = Typography;
|
const { Text: ATypographyText } = Typography;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const activityOpen = ref(false);
|
||||||
|
const highlighted = computed(
|
||||||
|
() => props.selected || props.peeking || activityOpen.value,
|
||||||
|
);
|
||||||
|
|
||||||
const showAmount = computed(
|
const showAmount = computed(
|
||||||
() => !props.copy.hideAmount && props.deal.amount != null,
|
() => !props.copy.hideAmount && props.deal.amount != null,
|
||||||
);
|
);
|
||||||
@@ -63,26 +75,97 @@ const extraAssignees = computed(() =>
|
|||||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||||
);
|
);
|
||||||
const showTrash = computed(() => props.selectMode && props.selected);
|
const showTrash = computed(() => props.selectMode && props.selected);
|
||||||
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
const dateHint = computed(() => {
|
||||||
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
if (!props.deal.dateIso) return props.deal.date;
|
||||||
high: { label: 'زیاد', color: '#ea580c' },
|
const d = new Date(props.deal.dateIso);
|
||||||
medium: { label: 'متوسط', color: '#eab308' },
|
if (Number.isNaN(d.getTime())) return props.deal.date;
|
||||||
low: { label: 'کم', color: '#16a34a' },
|
return formatJalaliNumeric(d);
|
||||||
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
});
|
||||||
};
|
const activityPriorities = useActivityPriorities();
|
||||||
const priorityFlag = computed(() =>
|
onMounted(() => {
|
||||||
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
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(() => ({
|
const cardStyle = computed(() => ({
|
||||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||||
opacity: props.dragging ? 0.4 : 1,
|
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو میکند؛ فقط سایه
|
||||||
transform: props.dragging ? 'scale(0.98)' : 'none',
|
opacity: props.dragging ? 0.55 : 1,
|
||||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
transition: 'opacity 120ms ease, box-shadow 120ms ease',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
position: highlighted.value ? 'relative' : undefined,
|
||||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
zIndex: highlighted.value ? 2 : undefined,
|
||||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -124,21 +207,29 @@ const cardStyle = computed(() => ({
|
|||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
<div :style="{ flex: 1, minWidth: 0 }">
|
<div :style="{ flex: 1, minWidth: 0 }">
|
||||||
<a-typography-text
|
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
|
||||||
ellipsis
|
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||||
:style="{
|
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
|
||||||
display: 'block',
|
</a-tooltip>
|
||||||
margin: 0,
|
<a-typography-text
|
||||||
lineHeight: 1.35,
|
ellipsis
|
||||||
cursor: 'pointer',
|
:style="{
|
||||||
fontSize: `${token.fontSizeSM}px`,
|
flex: 1,
|
||||||
fontWeight: 600,
|
minWidth: 0,
|
||||||
}"
|
display: 'block',
|
||||||
:title="deal.title"
|
margin: 0,
|
||||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
lineHeight: 1.35,
|
||||||
>
|
cursor: 'pointer',
|
||||||
{{ deal.title }}
|
fontSize: `${token.fontSizeSM}px`,
|
||||||
</a-typography-text>
|
fontWeight: 600,
|
||||||
|
}"
|
||||||
|
:title="deal.title"
|
||||||
|
@click.stop="onTitleClick"
|
||||||
|
@dblclick.stop
|
||||||
|
>
|
||||||
|
{{ deal.title }}
|
||||||
|
</a-typography-text>
|
||||||
|
</a-flex>
|
||||||
<a-typography-text
|
<a-typography-text
|
||||||
v-if="deal.description"
|
v-if="deal.description"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@@ -193,28 +284,75 @@ const cardStyle = computed(() => ({
|
|||||||
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
|
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
|
||||||
+{{ extraAssignees }}
|
+{{ extraAssignees }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
<a-tooltip :title="dateHint">
|
||||||
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
<a-typography-text
|
||||||
{{ deal.date }}
|
:type="dueTone === 'none' ? 'secondary' : undefined"
|
||||||
</a-typography-text>
|
:style="{
|
||||||
|
fontSize: `${token.fontSizeSM}px`,
|
||||||
|
color: dueTone !== 'none' ? dueToneColor : undefined,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
||||||
|
{{ deal.date }}
|
||||||
|
</a-typography-text>
|
||||||
|
</a-tooltip>
|
||||||
</a-space>
|
</a-space>
|
||||||
<a-typography-text
|
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
|
||||||
v-if="showAmount"
|
<a-typography-text
|
||||||
strong
|
v-if="showAmount"
|
||||||
dir="ltr"
|
strong
|
||||||
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
|
dir="ltr"
|
||||||
>
|
:style="{ margin: 0, whiteSpace: 'nowrap' }"
|
||||||
{{ money.number }}
|
>
|
||||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
{{ money.number }}
|
||||||
{{ money.unit }}
|
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||||
|
{{ money.unit }}
|
||||||
|
</a-typography-text>
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</a-typography-text>
|
<button
|
||||||
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
v-if="copy.quickView && !selectMode"
|
||||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
type="button"
|
||||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
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>
|
</span>
|
||||||
</a-tooltip>
|
</a-space>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-card>
|
</a-card>
|
||||||
</template>
|
</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;
|
copy?: PipelineUiCopy;
|
||||||
selectMode?: boolean;
|
selectMode?: boolean;
|
||||||
selectedIds?: Set<string>;
|
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<{
|
const emit = defineEmits<{
|
||||||
@@ -32,6 +34,7 @@ const emit = defineEmits<{
|
|||||||
drop: [stageId: string, event: DragEvent, index?: number];
|
drop: [stageId: string, event: DragEvent, index?: number];
|
||||||
addDeal: [stageId: string];
|
addDeal: [stageId: string];
|
||||||
openDeal: [dealId: string];
|
openDeal: [dealId: string];
|
||||||
|
detailDeal: [dealId: string];
|
||||||
menuDeal: [dealId: string];
|
menuDeal: [dealId: string];
|
||||||
toggleSelect: [dealId: string];
|
toggleSelect: [dealId: string];
|
||||||
bulkDelete: [];
|
bulkDelete: [];
|
||||||
@@ -109,6 +112,8 @@ function submitAdd() {
|
|||||||
:copy="copy"
|
:copy="copy"
|
||||||
:select-mode="selectMode"
|
:select-mode="selectMode"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
|
:peek-id="peekId"
|
||||||
|
:preview-id="previewId"
|
||||||
:is-first="index === 0"
|
:is-first="index === 0"
|
||||||
:is-last="index === stages.length - 1"
|
:is-last="index === stages.length - 1"
|
||||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||||
@@ -117,6 +122,7 @@ function submitAdd() {
|
|||||||
@drop="(id, e, index) => emit('drop', id, e, index)"
|
@drop="(id, e, index) => emit('drop', id, e, index)"
|
||||||
@add-deal="emit('addDeal', $event)"
|
@add-deal="emit('addDeal', $event)"
|
||||||
@open-deal="emit('openDeal', $event)"
|
@open-deal="emit('openDeal', $event)"
|
||||||
|
@detail-deal="emit('detailDeal', $event)"
|
||||||
@menu-deal="emit('menuDeal', $event)"
|
@menu-deal="emit('menuDeal', $event)"
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@bulk-delete="emit('bulkDelete')"
|
@bulk-delete="emit('bulkDelete')"
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ const props = withDefaults(
|
|||||||
copy?: PipelineUiCopy;
|
copy?: PipelineUiCopy;
|
||||||
selectMode?: boolean;
|
selectMode?: boolean;
|
||||||
selectedIds?: Set<string>;
|
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<{
|
const emit = defineEmits<{
|
||||||
@@ -37,6 +39,7 @@ const emit = defineEmits<{
|
|||||||
drop: [stageId: string, event: DragEvent, index?: number];
|
drop: [stageId: string, event: DragEvent, index?: number];
|
||||||
addDeal: [stageId: string];
|
addDeal: [stageId: string];
|
||||||
openDeal: [dealId: string];
|
openDeal: [dealId: string];
|
||||||
|
detailDeal: [dealId: string];
|
||||||
menuDeal: [dealId: string];
|
menuDeal: [dealId: string];
|
||||||
toggleSelect: [dealId: string];
|
toggleSelect: [dealId: string];
|
||||||
bulkDelete: [];
|
bulkDelete: [];
|
||||||
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
|
|||||||
:copy="copy"
|
:copy="copy"
|
||||||
:select-mode="selectMode"
|
:select-mode="selectMode"
|
||||||
:selected="selectedIds.has(deal.id)"
|
:selected="selectedIds.has(deal.id)"
|
||||||
|
:peeking="peekId === deal.id"
|
||||||
|
:previewing="previewId === deal.id"
|
||||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||||
@open="emit('openDeal', $event)"
|
@open="emit('openDeal', $event)"
|
||||||
|
@detail="emit('detailDeal', $event)"
|
||||||
@menu="emit('menuDeal', $event)"
|
@menu="emit('menuDeal', $event)"
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@bulk-delete="emit('bulkDelete')"
|
@bulk-delete="emit('bulkDelete')"
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import {
|
|||||||
Space as ASpace,
|
Space as ASpace,
|
||||||
Table as ATable,
|
Table as ATable,
|
||||||
Tag as ATag,
|
Tag as ATag,
|
||||||
|
Tooltip as ATooltip,
|
||||||
Typography,
|
Typography,
|
||||||
theme,
|
theme,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import type { TableColumnsType } from 'ant-design-vue';
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||||
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||||
import type {
|
import type {
|
||||||
PipelineAccent,
|
PipelineAccent,
|
||||||
PipelineDeal,
|
PipelineDeal,
|
||||||
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
|
|||||||
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال');
|
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 hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||||
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
|
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
|
||||||
|
|
||||||
@@ -213,10 +222,12 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'date'">
|
<template v-else-if="column.key === 'date'">
|
||||||
<a-space :size="6">
|
<a-tooltip :title="dateHint(record.deal)">
|
||||||
<CalendarOutlined />
|
<a-space :size="6">
|
||||||
<span>{{ record.deal.date }}</span>
|
<CalendarOutlined />
|
||||||
</a-space>
|
<span>{{ record.deal.date }}</span>
|
||||||
|
</a-space>
|
||||||
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<span :data-card-id="record.deal.id">
|
<span :data-card-id="record.deal.id">
|
||||||
@@ -334,10 +345,12 @@ onUnmounted(() => {
|
|||||||
{{ row.stageTitle }}
|
{{ row.stageTitle }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-flex justify="space-between" align="center">
|
<a-flex justify="space-between" align="center">
|
||||||
<a-space :size="6">
|
<a-tooltip :title="dateHint(row.deal)">
|
||||||
<CalendarOutlined />
|
<a-space :size="6">
|
||||||
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
|
<CalendarOutlined />
|
||||||
</a-space>
|
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
|
||||||
|
</a-space>
|
||||||
|
</a-tooltip>
|
||||||
<span v-if="!hideAmount" dir="ltr">
|
<span v-if="!hideAmount" dir="ltr">
|
||||||
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
|
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
|
||||||
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
|
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
PRIORITY_LABELS,
|
|
||||||
ROUTINES_CTX,
|
ROUTINES_CTX,
|
||||||
apiError,
|
apiError,
|
||||||
localInputToIso,
|
localInputToIso,
|
||||||
@@ -68,10 +68,10 @@ const form = reactive({
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
|
const activityPriorities = useActivityPriorities();
|
||||||
|
void activityPriorities.load();
|
||||||
const priorityOptions = computed(() =>
|
const priorityOptions = computed(() =>
|
||||||
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
|
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
|
||||||
([value, label]) => ({ value, label }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryOptions = computed(() => [
|
const categoryOptions = computed(() => [
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
|
|||||||
import { canAssignToOthers } from '@/lib/assign-access';
|
import { canAssignToOthers } from '@/lib/assign-access';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { formatJalaliDayClock } from '@/lib/jalali';
|
import { formatJalaliDayClock } from '@/lib/jalali';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
PRIORITY_LABELS,
|
|
||||||
PRIORITY_VARIANTS,
|
PRIORITY_VARIANTS,
|
||||||
ROUTINES_CTX,
|
ROUTINES_CTX,
|
||||||
ROUTINE_ENTITY_LINK_LABELS,
|
ROUTINE_ENTITY_LINK_LABELS,
|
||||||
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const canManage = computed(() => !!shell?.context.value?.isManager);
|
const canManage = computed(() => !!shell?.context.value?.isManager);
|
||||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
|
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() {
|
function startEdit() {
|
||||||
const t = task.value;
|
const t = task.value;
|
||||||
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
|
|||||||
<div class="badges">
|
<div class="badges">
|
||||||
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
|
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
|
||||||
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
|
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
|
||||||
اولویت {{ PRIORITY_LABELS[task.priority] }}
|
اولویت {{ activityPriorities.labelOf(task.priority) }}
|
||||||
</UiBadge>
|
</UiBadge>
|
||||||
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
|
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import {
|
|||||||
|
|
||||||
const AMenuItem = AMenu.Item;
|
const AMenuItem = AMenu.Item;
|
||||||
import UserAvatar from '@/components/UserAvatar.vue';
|
import UserAvatar from '@/components/UserAvatar.vue';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
PRIORITY_LABELS,
|
|
||||||
ROUTINE_TASK_SOURCE_LABELS,
|
ROUTINE_TASK_SOURCE_LABELS,
|
||||||
ROUTINE_TASK_STATUS_LABELS,
|
ROUTINE_TASK_STATUS_LABELS,
|
||||||
entityRoute,
|
entityRoute,
|
||||||
@@ -64,6 +64,9 @@ const emit = defineEmits<{
|
|||||||
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
|
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const activityPriorities = useActivityPriorities();
|
||||||
|
void activityPriorities.load();
|
||||||
|
|
||||||
const done = computed(() => props.task.status === 'completed');
|
const done = computed(() => props.task.status === 'completed');
|
||||||
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
|
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
|
||||||
const open = computed(() => isOpenStatus(props.task.status));
|
const open = computed(() => isOpenStatus(props.task.status));
|
||||||
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
|
|||||||
<CheckOutlined /> {{ formatTime(task.completedAt) }}
|
<CheckOutlined /> {{ formatTime(task.completedAt) }}
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
|
<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">
|
<a-tag v-if="task.status !== 'todo'" :bordered="false">
|
||||||
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
|
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||||
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
||||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const scroller = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const role = auth.user?.role;
|
const role = auth.user?.role;
|
||||||
@@ -41,6 +42,17 @@ const active = computed(() => {
|
|||||||
return '/finance';
|
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) {
|
function onChange(value: string | number) {
|
||||||
const next = String(value);
|
const next = String(value);
|
||||||
if (next && next !== route.path) void router.push(next);
|
if (next && next !== route.path) void router.push(next);
|
||||||
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="scroller"
|
||||||
:style="{
|
:style="{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
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 type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||||
import { toman } from '@/views/treasury/treasury-format';
|
import { toman } from '@/views/treasury/treasury-format';
|
||||||
|
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
rows: TreasuryPartyBalanceDto[];
|
rows: TreasuryPartyBalanceDto[];
|
||||||
settledLabel: string;
|
settledLabel: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const isMobile = useTreasuryMobile();
|
||||||
|
const { Text: ATypographyText } = Typography;
|
||||||
|
const AListItem = AList.Item;
|
||||||
|
|
||||||
const kindLabel: Record<string, string> = {
|
const kindLabel: Record<string, string> = {
|
||||||
person: 'مشتری',
|
person: 'مشتری',
|
||||||
company: 'شرکت',
|
company: 'شرکت',
|
||||||
@@ -39,10 +49,43 @@ const columns = computed(() => [
|
|||||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function kindOf(record: TreasuryPartyBalanceDto) {
|
||||||
|
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<a-table
|
||||||
|
v-else
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="rows"
|
:data-source="rows"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
|
|||||||
const ASelectOptGroup = ASelect.OptGroup;
|
const ASelectOptGroup = ASelect.OptGroup;
|
||||||
const { Text: ATypographyText } = Typography;
|
const { Text: ATypographyText } = Typography;
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
|
||||||
|
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
|
||||||
|
);
|
||||||
|
|
||||||
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||||
const partyId = defineModel<string | undefined>('partyId');
|
const partyId = defineModel<string | undefined>('partyId');
|
||||||
|
|
||||||
@@ -156,12 +161,12 @@ onMounted(() => {
|
|||||||
:filter-option="false"
|
:filter-option="false"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:value="selectValue"
|
:value="selectValue"
|
||||||
placeholder="مشتری، شرکت، کاربر یا سایر"
|
:placeholder="placeholder"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
@change="onChange"
|
@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-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||||
<a-select-option
|
<a-select-option
|
||||||
v-for="row in peopleOptions"
|
v-for="row in peopleOptions"
|
||||||
|
|||||||
@@ -184,8 +184,43 @@ function onSizeChange(e: Event) {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ui-pagination {
|
.ui-pagination {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: var(--sp-3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||||
import {
|
import {
|
||||||
canOpenPath,
|
canOpenPath,
|
||||||
|
firstAllowedFinancePath,
|
||||||
hasSectionPermission,
|
hasSectionPermission,
|
||||||
sectionPermissionForPath,
|
sectionPermissionForPath,
|
||||||
} from '@/lib/section-permissions';
|
} from '@/lib/section-permissions';
|
||||||
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
|
|||||||
}) {
|
}) {
|
||||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||||
const perms = auth.user?.permissions ?? [];
|
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')) {
|
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 section = card.to ? sectionPermissionForPath(card.to) : null;
|
||||||
const hasSection = section
|
const hasSection = section
|
||||||
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
|
|||||||
if (!openHubKey.value) return null;
|
if (!openHubKey.value) return null;
|
||||||
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
|
||||||
if (!hub) return null;
|
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 (
|
if (
|
||||||
hub.key === 'customers' &&
|
hub.key === 'customers' &&
|
||||||
isMobileNav.value &&
|
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)} روز پیش`;
|
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 {
|
export function formatNextSlot(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { currencySuffix } from '@/lib/money';
|
import { currencySuffix } from '@/lib/money';
|
||||||
import { matchesSearch } from '@/lib/search';
|
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';
|
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
|
||||||
|
|
||||||
@@ -10,6 +15,8 @@ export type PipelineDeal = {
|
|||||||
description: string;
|
description: string;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
date: string;
|
date: string;
|
||||||
|
/** ISO برای tooltip تاریخ مطلق */
|
||||||
|
dateIso?: string;
|
||||||
initials: string;
|
initials: string;
|
||||||
/** شناسه مرحله فعلی — هنگام جابهجایی بهروز میشود */
|
/** شناسه مرحله فعلی — هنگام جابهجایی بهروز میشود */
|
||||||
stageId?: string;
|
stageId?: string;
|
||||||
@@ -20,10 +27,18 @@ export type PipelineDeal = {
|
|||||||
assigneeNames?: string[];
|
assigneeNames?: string[];
|
||||||
ownerId?: string | null;
|
ownerId?: string | null;
|
||||||
assigneeIds?: string[];
|
assigneeIds?: string[];
|
||||||
|
personId?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
||||||
searchFields?: Array<string | null | undefined>;
|
searchFields?: Array<string | null | undefined>;
|
||||||
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده میشود */
|
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده میشود */
|
||||||
priority?: ActivityPriority;
|
priority?: ActivityPriority;
|
||||||
|
/** وضعیت فعالیت (برای کارت کانبان فعالیت: انجامشده و …) */
|
||||||
|
activityStatus?: ActivityStatus;
|
||||||
|
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
|
||||||
|
activityCue?: DealActivityCue;
|
||||||
|
/** خلاصه فعالیتها برای پاپآپ دکمهٔ تقویم */
|
||||||
|
activitySummaries?: DealActivitySummaryDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineUiCopy = {
|
export type PipelineUiCopy = {
|
||||||
@@ -34,6 +49,10 @@ export type PipelineUiCopy = {
|
|||||||
itemMenuAria: string;
|
itemMenuAria: string;
|
||||||
listColTitle: string;
|
listColTitle: string;
|
||||||
hideAmount?: boolean;
|
hideAmount?: boolean;
|
||||||
|
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه بهجای ورود مستقیم به جزئیات */
|
||||||
|
quickView?: boolean;
|
||||||
|
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
|
||||||
|
quickActivity?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
||||||
@@ -43,6 +62,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
|||||||
addItemAria: 'افزودن معامله به این مرحله',
|
addItemAria: 'افزودن معامله به این مرحله',
|
||||||
itemMenuAria: 'گزینههای معامله',
|
itemMenuAria: 'گزینههای معامله',
|
||||||
listColTitle: 'معامله',
|
listColTitle: 'معامله',
|
||||||
|
quickActivity: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
|
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
|
||||||
@@ -63,6 +83,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
|
|||||||
itemMenuAria: 'گزینههای فعالیت',
|
itemMenuAria: 'گزینههای فعالیت',
|
||||||
listColTitle: 'فعالیت',
|
listColTitle: 'فعالیت',
|
||||||
hideAmount: true,
|
hideAmount: true,
|
||||||
|
quickView: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineStage = {
|
export type PipelineStage = {
|
||||||
@@ -182,8 +203,10 @@ export function mapBoardToStages(
|
|||||||
expectedCloseAt: string | null;
|
expectedCloseAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
promotedFromPriorAt: string | null;
|
promotedFromPriorAt: string | null;
|
||||||
person: { fullName: string } | null;
|
activityCue?: DealActivityCue;
|
||||||
company: { name: string } | null;
|
activitySummaries?: DealActivitySummaryDto[];
|
||||||
|
person: { id?: string; fullName: string } | null;
|
||||||
|
company: { id?: string; name: string } | null;
|
||||||
owner?: { id: string; fullName: string } | null;
|
owner?: { id: string; fullName: string } | null;
|
||||||
assignees?: Array<{ id: string; fullName: string }>;
|
assignees?: Array<{ id: string; fullName: string }>;
|
||||||
}>;
|
}>;
|
||||||
@@ -226,6 +249,9 @@ export function mapBoardToStages(
|
|||||||
description,
|
description,
|
||||||
amount: deal.amount ?? 0,
|
amount: deal.amount ?? 0,
|
||||||
date: formatDate(dateIso),
|
date: formatDate(dateIso),
|
||||||
|
dateIso,
|
||||||
|
activityCue: deal.activityCue ?? 'none',
|
||||||
|
activitySummaries: deal.activitySummaries ?? [],
|
||||||
initials: initialsFromTitle(
|
initials: initialsFromTitle(
|
||||||
deal.company?.name || deal.title,
|
deal.company?.name || deal.title,
|
||||||
),
|
),
|
||||||
@@ -236,6 +262,8 @@ export function mapBoardToStages(
|
|||||||
assigneeNames,
|
assigneeNames,
|
||||||
ownerId: deal.owner?.id ?? null,
|
ownerId: deal.owner?.id ?? null,
|
||||||
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
|
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
|
||||||
|
personId: deal.person?.id ?? null,
|
||||||
|
companyId: deal.company?.id ?? null,
|
||||||
searchFields: [
|
searchFields: [
|
||||||
deal.notes,
|
deal.notes,
|
||||||
deal.company?.name,
|
deal.company?.name,
|
||||||
@@ -291,6 +319,7 @@ export function mapProjectBoardToStages(
|
|||||||
'',
|
'',
|
||||||
amount: null,
|
amount: null,
|
||||||
date: formatDate(card.createdAt),
|
date: formatDate(card.createdAt),
|
||||||
|
dateIso: card.createdAt,
|
||||||
initials: initialsFromTitle(
|
initials: initialsFromTitle(
|
||||||
card.company?.name || card.person?.fullName || card.title,
|
card.company?.name || card.person?.fullName || card.title,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export {
|
|||||||
|
|
||||||
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
|
/** پیشفرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
|
||||||
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
||||||
very_low: 'خیلی کم',
|
very_low: 'خیلی کم',
|
||||||
low: 'کم',
|
low: 'کم',
|
||||||
|
|||||||
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
|
|||||||
return treasurySectionAllowed(role, permissions, section);
|
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(
|
export function canOpenPath(
|
||||||
role: UserRole | string | null | undefined,
|
role: UserRole | string | null | undefined,
|
||||||
permissions: readonly 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 type { UserRole } from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { usePlatformAuthStore } from '@/stores/platform-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({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -330,7 +335,8 @@ export const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
roles: ['owner', 'admin'] as UserRole[],
|
roles: ['owner', 'admin'] as UserRole[],
|
||||||
treasuryTitle: 'حقوق و دستمزد',
|
treasuryTitle: 'حقوق و دستمزد',
|
||||||
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
|
treasuryDescription:
|
||||||
|
'تعیین حقوق، پرداخت یکجا یا چندمرحلهای، و گزارش دورهها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -350,7 +356,8 @@ export const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
roles: ['owner', 'admin'] as UserRole[],
|
roles: ['owner', 'admin'] as UserRole[],
|
||||||
treasuryTitle: 'گزارش مالی',
|
treasuryTitle: 'گزارش مالی',
|
||||||
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.',
|
treasuryDescription:
|
||||||
|
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یکجا نشان میدهند.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -681,13 +688,8 @@ router.beforeEach((to) => {
|
|||||||
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const exactHome = to.path === '/finance' || to.path === '/finance/';
|
const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
|
||||||
if (
|
if (next && next !== to.path) return { path: next };
|
||||||
exactHome &&
|
|
||||||
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
|
|
||||||
) {
|
|
||||||
return { path: '/finance/payroll' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { name: 'home' };
|
return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
if (token.value) void hydrate();
|
if (token.value) void hydrate();
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') void hydrate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
|
|||||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||||
|
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_EXCEL_ALIASES,
|
ACTIVITY_EXCEL_ALIASES,
|
||||||
@@ -42,6 +43,7 @@ import type {
|
|||||||
ActivityBoardDto,
|
ActivityBoardDto,
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
ActivityFilter,
|
ActivityFilter,
|
||||||
|
ActivityPriority,
|
||||||
ActivityStageCountsDto,
|
ActivityStageCountsDto,
|
||||||
ActivityStageDto,
|
ActivityStageDto,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
@@ -50,9 +52,12 @@ import type {
|
|||||||
OrgUserDto,
|
OrgUserDto,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { brand } from '@mento/shared';
|
import { ACTIVITY_PRIORITY_KEYS, brand } from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { canMutateActivity } from '@/lib/activity-access';
|
||||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
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 { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||||
@@ -91,6 +96,7 @@ const actGridStyle = computed(() => ({
|
|||||||
|
|
||||||
/** 'all' یا شناسهٔ نوع فعالیت */
|
/** 'all' یا شناسهٔ نوع فعالیت */
|
||||||
type TypeFilter = string;
|
type TypeFilter = string;
|
||||||
|
type PriorityFilter = 'all' | ActivityPriority;
|
||||||
type StatusFilter = 'all' | ActivityStatus;
|
type StatusFilter = 'all' | ActivityStatus;
|
||||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||||
type DeskScope = 'mine' | 'team';
|
type DeskScope = 'mine' | 'team';
|
||||||
@@ -118,6 +124,18 @@ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
|||||||
{ key: 'canceled', label: 'لغو شده' },
|
{ 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 pageSize = usePageSize('activities', 10);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -177,6 +195,7 @@ const boardDeleteOpen = ref(false);
|
|||||||
|
|
||||||
async function onTypesChanged() {
|
async function onTypesChanged() {
|
||||||
await activityTypes.load(true);
|
await activityTypes.load(true);
|
||||||
|
await activityPriorities.load(true);
|
||||||
kanbanReload.value += 1;
|
kanbanReload.value += 1;
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
}
|
}
|
||||||
@@ -201,6 +220,7 @@ const page = computed(() => {
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const notice = ref('');
|
||||||
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
|
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
|
||||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
@@ -239,6 +259,13 @@ const statusFilter = computed<StatusFilter>(() => {
|
|||||||
: 'all';
|
: '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(() =>
|
const assigneeFilter = computed(() =>
|
||||||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||||||
);
|
);
|
||||||
@@ -317,6 +344,7 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
|
|||||||
? legacyBaseType.value
|
? legacyBaseType.value
|
||||||
: undefined,
|
: undefined,
|
||||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||||
|
priority: priorityFilter.value === 'all' ? undefined : priorityFilter.value,
|
||||||
assigneeId: assigneeFilter.value || undefined,
|
assigneeId: assigneeFilter.value || undefined,
|
||||||
title: titleFilter.value.trim() || undefined,
|
title: titleFilter.value.trim() || undefined,
|
||||||
customer: customerFilter.value.trim() || undefined,
|
customer: customerFilter.value.trim() || undefined,
|
||||||
@@ -329,6 +357,7 @@ function stageDotStyle(s: ActivityStageDto) {
|
|||||||
const activeFacetCount = computed(() => {
|
const activeFacetCount = computed(() => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
if (typeFilter.value !== 'all') n += 1;
|
if (typeFilter.value !== 'all') n += 1;
|
||||||
|
if (priorityFilter.value !== 'all') n += 1;
|
||||||
if (statusFilter.value !== 'all') n += 1;
|
if (statusFilter.value !== 'all') n += 1;
|
||||||
if (assigneeFilter.value) n += 1;
|
if (assigneeFilter.value) n += 1;
|
||||||
if (titleFilter.value.trim()) 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 (search) query.q = search;
|
||||||
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
|
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
|
||||||
if (statusFilter.value !== 'all') query.status = statusFilter.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 (assigneeFilter.value) query.assigneeId = assigneeFilter.value;
|
||||||
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
|
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
|
||||||
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
|
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
|
||||||
@@ -445,23 +475,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityRoute(id: string, edit = false, columnStageId?: string) {
|
function activityRoute(id: string, 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;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
name: 'activity-edit' as const,
|
name: 'activity-detail' as const,
|
||||||
params: { id },
|
params: { id },
|
||||||
query,
|
query: deskDetailQuery(columnStageId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +799,13 @@ function setStatusFilter(next: string) {
|
|||||||
patchFacetQuery({ status: value === 'all' ? undefined : value });
|
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) {
|
function setAssigneeFilter(next: string) {
|
||||||
patchFacetQuery({
|
patchFacetQuery({
|
||||||
assigneeId: next || undefined,
|
assigneeId: next || undefined,
|
||||||
@@ -805,6 +830,7 @@ function clearFacets() {
|
|||||||
type: undefined,
|
type: undefined,
|
||||||
typeDefId: undefined,
|
typeDefId: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
priority: undefined,
|
||||||
assigneeId: undefined,
|
assigneeId: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
customer: undefined,
|
customer: undefined,
|
||||||
@@ -837,6 +863,30 @@ function onFacetDone() {
|
|||||||
openCol.value = null;
|
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) {
|
async function complete(id: string) {
|
||||||
menuId.value = null;
|
menuId.value = null;
|
||||||
boardMenuId.value = null;
|
boardMenuId.value = null;
|
||||||
@@ -913,10 +963,27 @@ function clearSelect() {
|
|||||||
selectedIds.value = new Set();
|
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) {
|
function askDeleteActivity(id: string, title?: string) {
|
||||||
menuId.value = null;
|
menuId.value = null;
|
||||||
boardMenuId.value = null;
|
boardMenuId.value = null;
|
||||||
|
const target = activityById(id);
|
||||||
boardMenuActivity.value = null;
|
boardMenuActivity.value = null;
|
||||||
|
if (target && !canDeleteActivity(target)) {
|
||||||
|
message.warning('شما مجوز حذف این فعالیت را ندارید.');
|
||||||
|
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
deleteSingleTitle.value = title?.trim() || activityTitle(id);
|
deleteSingleTitle.value = title?.trim() || activityTitle(id);
|
||||||
deleteActivityIds.value = [id];
|
deleteActivityIds.value = [id];
|
||||||
}
|
}
|
||||||
@@ -926,8 +993,24 @@ function askBulkDelete() {
|
|||||||
menuId.value = null;
|
menuId.value = null;
|
||||||
boardMenuId.value = null;
|
boardMenuId.value = null;
|
||||||
boardMenuActivity.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 = '';
|
deleteSingleTitle.value = '';
|
||||||
deleteActivityIds.value = [...selectedIds.value];
|
deleteActivityIds.value = allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteActivities() {
|
async function confirmDeleteActivities() {
|
||||||
@@ -937,16 +1020,24 @@ async function confirmDeleteActivities() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
|
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 = [];
|
deleteActivityIds.value = [];
|
||||||
clearSelect();
|
clearSelect();
|
||||||
kanbanReload.value += 1;
|
kanbanReload.value += 1;
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
if (failed) {
|
if (failed.length) {
|
||||||
|
const first = failed[0];
|
||||||
|
const detail =
|
||||||
|
first && first.status === 'rejected'
|
||||||
|
? apiError(first.reason, '')
|
||||||
|
: '';
|
||||||
error.value =
|
error.value =
|
||||||
failed === ids.length
|
failed.length === ids.length
|
||||||
? 'حذف فعالیتها ناموفق بود.'
|
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
|
||||||
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
: detail
|
||||||
|
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
|
||||||
|
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
|
||||||
|
message.error(error.value);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
activityDeleting.value = false;
|
activityDeleting.value = false;
|
||||||
@@ -1105,6 +1196,7 @@ onMounted(() => {
|
|||||||
mobileQuery.addEventListener('change', syncMobile);
|
mobileQuery.addEventListener('change', syncMobile);
|
||||||
void loadUsers();
|
void loadUsers();
|
||||||
void activityTypes.load(true);
|
void activityTypes.load(true);
|
||||||
|
void activityPriorities.load();
|
||||||
void loadBoards()
|
void loadBoards()
|
||||||
.then(() => loadStages())
|
.then(() => loadStages())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1160,11 +1252,11 @@ onUnmounted(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
title="انواع فعالیت و فیلدها"
|
title="انواع فعالیت و اولویت ها"
|
||||||
@click="typesManagerOpen = true"
|
@click="typesManagerOpen = true"
|
||||||
>
|
>
|
||||||
<UiIcon name="sliders" :size="14" />
|
<UiIcon name="sliders" :size="14" />
|
||||||
<span class="manage-label">انواع فعالیت و فیلدها</span>
|
<span class="manage-label">انواع فعالیت و اولویت ها</span>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<UiButton
|
<UiButton
|
||||||
v-if="canManageStages"
|
v-if="canManageStages"
|
||||||
@@ -1323,7 +1415,7 @@ onUnmounted(() => {
|
|||||||
<hr class="board-menu-sep" />
|
<hr class="board-menu-sep" />
|
||||||
<button type="button" class="menu-row" @click="typesManagerOpen = true">
|
<button type="button" class="menu-row" @click="typesManagerOpen = true">
|
||||||
<UiIcon name="sliders" :size="16" />
|
<UiIcon name="sliders" :size="16" />
|
||||||
انواع فعالیت و فیلدها
|
انواع فعالیت و اولویت ها
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
|
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
|
||||||
<UiIcon name="settings" :size="16" />
|
<UiIcon name="settings" :size="16" />
|
||||||
@@ -1402,16 +1494,19 @@ onUnmounted(() => {
|
|||||||
:customer="customerFilter"
|
:customer="customerFilter"
|
||||||
:due="dueFilter"
|
:due="dueFilter"
|
||||||
:type-key="typeFilter"
|
:type-key="typeFilter"
|
||||||
|
:priority-key="priorityFilter"
|
||||||
:status-key="statusFilter"
|
:status-key="statusFilter"
|
||||||
:assignee-id="assigneeFilter"
|
:assignee-id="assigneeFilter"
|
||||||
:show-assignee="showDeskTabs"
|
:show-assignee="showDeskTabs"
|
||||||
:users="users"
|
:users="users"
|
||||||
:types="TYPE_FILTERS"
|
:types="TYPE_FILTERS"
|
||||||
|
:priorities="PRIORITY_FILTERS"
|
||||||
:statuses="STATUS_FILTERS"
|
:statuses="STATUS_FILTERS"
|
||||||
@title="setTitleFilter"
|
@title="setTitleFilter"
|
||||||
@customer="setCustomerFilter"
|
@customer="setCustomerFilter"
|
||||||
@due="setDueFilter"
|
@due="setDueFilter"
|
||||||
@type="setTypeFilter"
|
@type="setTypeFilter"
|
||||||
|
@priority="setPriorityFilter"
|
||||||
@status="setStatusFilter"
|
@status="setStatusFilter"
|
||||||
@assignee="setAssigneeFilter"
|
@assignee="setAssigneeFilter"
|
||||||
@clear="clearFacets"
|
@clear="clearFacets"
|
||||||
@@ -1503,6 +1598,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="ok">{{ notice }}</p>
|
||||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||||
|
|
||||||
<div v-if="selectMode" class="select-bar">
|
<div v-if="selectMode" class="select-bar">
|
||||||
@@ -1532,6 +1628,7 @@ onUnmounted(() => {
|
|||||||
@stages-changed="onStagesChanged"
|
@stages-changed="onStagesChanged"
|
||||||
@add="openAddInStage"
|
@add="openAddInStage"
|
||||||
@error="(msg: string) => (error = msg)"
|
@error="(msg: string) => (error = msg)"
|
||||||
|
@notice="(msg: string) => (notice = msg)"
|
||||||
@menu-deal="onBoardMenu"
|
@menu-deal="onBoardMenu"
|
||||||
@toggle-select="toggleSelect"
|
@toggle-select="toggleSelect"
|
||||||
@bulk-delete="askBulkDelete"
|
@bulk-delete="askBulkDelete"
|
||||||
@@ -1619,16 +1716,19 @@ onUnmounted(() => {
|
|||||||
:customer="customerFilter"
|
:customer="customerFilter"
|
||||||
:due="dueFilter"
|
:due="dueFilter"
|
||||||
:type-key="typeFilter"
|
:type-key="typeFilter"
|
||||||
|
:priority-key="priorityFilter"
|
||||||
:status-key="statusFilter"
|
:status-key="statusFilter"
|
||||||
:assignee-id="assigneeFilter"
|
:assignee-id="assigneeFilter"
|
||||||
:show-assignee="showDeskTabs"
|
:show-assignee="showDeskTabs"
|
||||||
:users="users"
|
:users="users"
|
||||||
:types="TYPE_FILTERS"
|
:types="TYPE_FILTERS"
|
||||||
|
:priorities="PRIORITY_FILTERS"
|
||||||
:statuses="STATUS_FILTERS"
|
:statuses="STATUS_FILTERS"
|
||||||
@title="setTitleFilter"
|
@title="setTitleFilter"
|
||||||
@customer="setCustomerFilter"
|
@customer="setCustomerFilter"
|
||||||
@due="setDueFilter"
|
@due="setDueFilter"
|
||||||
@type="setTypeFilter"
|
@type="setTypeFilter"
|
||||||
|
@priority="setPriorityFilter"
|
||||||
@status="setStatusFilter"
|
@status="setStatusFilter"
|
||||||
@assignee="setAssigneeFilter"
|
@assignee="setAssigneeFilter"
|
||||||
@done="onFacetDone"
|
@done="onFacetDone"
|
||||||
@@ -1749,16 +1849,18 @@ onUnmounted(() => {
|
|||||||
<RouterLink :to="activityRoute(item.id)">
|
<RouterLink :to="activityRoute(item.id)">
|
||||||
مشاهده
|
مشاهده
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="activityRoute(item.id, true)">
|
|
||||||
ویرایش
|
|
||||||
</RouterLink>
|
|
||||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||||
انتقال به کار جاری
|
انتقال به کار جاری
|
||||||
</button>
|
</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>
|
||||||
<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>
|
</button>
|
||||||
</UiDropdownMenu>
|
</UiDropdownMenu>
|
||||||
@@ -1839,16 +1941,18 @@ onUnmounted(() => {
|
|||||||
<RouterLink :to="activityRoute(item.id)">
|
<RouterLink :to="activityRoute(item.id)">
|
||||||
مشاهده
|
مشاهده
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="activityRoute(item.id, true)">
|
|
||||||
ویرایش
|
|
||||||
</RouterLink>
|
|
||||||
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
|
||||||
انتقال به کار جاری
|
انتقال به کار جاری
|
||||||
</button>
|
</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>
|
||||||
<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>
|
</button>
|
||||||
</UiDropdownMenu>
|
</UiDropdownMenu>
|
||||||
@@ -1895,18 +1999,11 @@ onUnmounted(() => {
|
|||||||
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
|
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
|
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
|
||||||
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
|
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
|
||||||
>
|
>
|
||||||
مشاهده
|
مشاهده
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
|
||||||
role="menuitem"
|
|
||||||
:to="activityRoute(boardMenuId, true)"
|
|
||||||
@click="boardMenuId = null; boardMenuActivity = null"
|
|
||||||
>
|
|
||||||
ویرایش
|
|
||||||
</RouterLink>
|
|
||||||
<button
|
<button
|
||||||
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
|
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1916,6 +2013,7 @@ onUnmounted(() => {
|
|||||||
انتقال به کار جاری
|
انتقال به کار جاری
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canDeleteActivity(boardMenuActivity)"
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
class="danger"
|
class="danger"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user