merge: bring latest origin/main into dev (design local, logic main)
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
|
||||||
@@ -353,6 +353,10 @@ model OrgRole {
|
|||||||
permissions String[] @default([])
|
permissions String[] @default([])
|
||||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||||
dataAccessPolicy Json @default("{}")
|
dataAccessPolicy Json @default("{}")
|
||||||
|
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
||||||
|
treasuryAccountIds String[] @default([])
|
||||||
|
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
||||||
|
treasuryOwnAccounts Boolean @default(false)
|
||||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||||
systemKey String?
|
systemKey String?
|
||||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||||
@@ -387,6 +391,8 @@ model Membership {
|
|||||||
dataVisibilityScopes Json @default("{}")
|
dataVisibilityScopes Json @default("{}")
|
||||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||||
dataAccessOverride Json @default("{}")
|
dataAccessOverride Json @default("{}")
|
||||||
|
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
||||||
|
treasuryAccessOverride Json?
|
||||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||||
priorArchiveEnabled Boolean @default(false)
|
priorArchiveEnabled Boolean @default(false)
|
||||||
/// current | archive
|
/// current | archive
|
||||||
@@ -896,6 +902,8 @@ model Activity {
|
|||||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||||
stageId String?
|
stageId String?
|
||||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||||
|
/// ترتیب کارت داخل همان ستون کانبان
|
||||||
|
boardPosition Int @default(0)
|
||||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||||
typeDefId String?
|
typeDefId String?
|
||||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||||
@@ -911,6 +919,7 @@ model Activity {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([stageId])
|
@@index([stageId])
|
||||||
|
@@index([stageId, boardPosition])
|
||||||
@@index([boardId])
|
@@index([boardId])
|
||||||
@@index([typeDefId])
|
@@index([typeDefId])
|
||||||
@@index([leadId])
|
@@index([leadId])
|
||||||
|
|||||||
@@ -428,7 +428,13 @@ export class ActivitiesController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: MoveActivityStageDto,
|
@Body() dto: MoveActivityStageDto,
|
||||||
) {
|
) {
|
||||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
return this.activitiesService.moveToStage(
|
||||||
|
user.organizationId,
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
dto.stageId,
|
||||||
|
dto.position,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/complete')
|
@Patch(':id/complete')
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
ownerWhere,
|
ownerWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { assertAssigneeSet } from '../common/assign-access';
|
import { assertAssigneeSet } from '../common/assign-access';
|
||||||
|
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { tehranDayKey } from '../routines/routine-dates';
|
import { tehranDayKey } from '../routines/routine-dates';
|
||||||
@@ -329,9 +330,7 @@ export class ActivitiesService {
|
|||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||||
orderBy: entity?.leadId
|
orderBy: this.listOrder(entity, stageScope),
|
||||||
? [{ createdAt: 'desc' }]
|
|
||||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
|
||||||
skip: (page - 1) * take,
|
skip: (page - 1) * take,
|
||||||
take,
|
take,
|
||||||
}),
|
}),
|
||||||
@@ -385,7 +384,7 @@ export class ActivitiesService {
|
|||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
where,
|
where,
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
orderBy: this.listOrder(entity, stageScope),
|
||||||
take: cap,
|
take: cap,
|
||||||
}),
|
}),
|
||||||
this.prisma.activity.count({ where }),
|
this.prisma.activity.count({ where }),
|
||||||
@@ -490,11 +489,46 @@ export class ActivitiesService {
|
|||||||
return { counts, total };
|
return { counts, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private listOrder(
|
||||||
|
entity?: { leadId?: string },
|
||||||
|
stageScope?: StageScope,
|
||||||
|
): Prisma.ActivityOrderByWithRelationInput[] {
|
||||||
|
if (entity?.leadId) return [{ createdAt: 'desc' }];
|
||||||
|
if (stageScope && stageScope.stageId !== 'all') {
|
||||||
|
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||||
|
}
|
||||||
|
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reorderStage(
|
||||||
|
organizationId: string,
|
||||||
|
stageId: string,
|
||||||
|
activityId: string,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
const where = await this.stages.whereForStage(organizationId, stageId);
|
||||||
|
const rows = await this.prisma.activity.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
const currentIds = rows.map((row) => row.id);
|
||||||
|
const placed = placeId(currentIds, activityId, index);
|
||||||
|
if (sameIds(currentIds, placed)) return;
|
||||||
|
await writePositions(
|
||||||
|
(ops) => this.prisma.$transaction(ops),
|
||||||
|
placed,
|
||||||
|
(id, boardPosition) =>
|
||||||
|
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async moveToStage(
|
async moveToStage(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
id: string,
|
id: string,
|
||||||
actor: ActivityActor,
|
actor: ActivityActor,
|
||||||
stageId: string,
|
stageId: string,
|
||||||
|
position?: number,
|
||||||
): Promise<ActivityDto> {
|
): Promise<ActivityDto> {
|
||||||
await this.ensureExists(organizationId, id, actor);
|
await this.ensureExists(organizationId, id, actor);
|
||||||
const stage = await this.stages.get(organizationId, stageId);
|
const stage = await this.stages.get(organizationId, stageId);
|
||||||
@@ -519,6 +553,7 @@ export class ActivitiesService {
|
|||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
|
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
|
||||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||||
await this.fireDone(organizationId, row);
|
await this.fireDone(organizationId, row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
|
|||||||
export class MoveActivityStageDto {
|
export class MoveActivityStageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
stageId!: string;
|
stageId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||||
@@ -316,7 +322,7 @@ export class CreateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -416,7 +422,7 @@ export class UpdateActivityDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(20000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
type PositionUpdate = Prisma.PrismaPromise<unknown>;
|
||||||
|
|
||||||
|
/** کارت را در ایندکس خواستهشده میگذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
|
||||||
|
export function placeId(ids: string[], movingId: string, index: number): string[] {
|
||||||
|
const next = ids.filter((id) => id !== movingId);
|
||||||
|
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
|
||||||
|
next.splice(at, 0, movingId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameIds(left: string[], right: string[]): boolean {
|
||||||
|
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ترتیب ستون را ۰ تا n ذخیره میکند. */
|
||||||
|
export async function writePositions(
|
||||||
|
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
|
||||||
|
ids: string[],
|
||||||
|
update: (id: string, position: number) => PositionUpdate,
|
||||||
|
) {
|
||||||
|
const size = 60;
|
||||||
|
for (let start = 0; start < ids.length; start += size) {
|
||||||
|
const slice = ids.slice(start, start + size);
|
||||||
|
await transaction(slice.map((id, offset) => update(id, start + offset)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
forScope,
|
forScope,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||||
|
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||||
import {
|
import {
|
||||||
asAddresses,
|
asAddresses,
|
||||||
collectAddresses,
|
collectAddresses,
|
||||||
@@ -613,24 +614,43 @@ export class LeadsService {
|
|||||||
});
|
});
|
||||||
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
||||||
|
|
||||||
|
let placedIds: string[] | null = null;
|
||||||
|
let nextPosition = position ?? 0;
|
||||||
|
if (position != null) {
|
||||||
|
const rows = await this.prisma.lead.findMany({
|
||||||
|
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||||
|
});
|
||||||
|
const currentIds = rows.map((row) => row.id);
|
||||||
|
placedIds = placeId(currentIds, id, position);
|
||||||
|
nextPosition = Math.max(0, placedIds.indexOf(id));
|
||||||
|
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.prisma.lead.update({
|
const row = await this.prisma.lead.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
stageId,
|
stageId,
|
||||||
position: position ?? 0,
|
position: nextPosition,
|
||||||
...(stage.isConverted
|
|
||||||
? {}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
include: leadInclude,
|
include: leadInclude,
|
||||||
});
|
});
|
||||||
await this.addEvent(
|
if (placedIds) {
|
||||||
actor,
|
await writePositions(
|
||||||
id,
|
(ops) => this.prisma.$transaction(ops),
|
||||||
'stage_changed',
|
placedIds,
|
||||||
`به مرحله «${stage.name}» منتقل شد`,
|
(leadId, leadPosition) =>
|
||||||
);
|
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (before.stageId !== stageId) {
|
if (before.stageId !== stageId) {
|
||||||
|
await this.addEvent(
|
||||||
|
actor,
|
||||||
|
id,
|
||||||
|
'stage_changed',
|
||||||
|
`به مرحله «${stage.name}» منتقل شد`,
|
||||||
|
);
|
||||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||||
eventKey: `${id}:${stageId}`,
|
eventKey: `${id}:${stageId}`,
|
||||||
facts: { stageName: stage.name, source: row.source },
|
facts: { stageName: stage.name, source: row.source },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
|
|||||||
@ValidateIf((_, v) => v !== null)
|
@ValidateIf((_, v) => v !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateOrgRoleDto {
|
export class UpdateOrgRoleDto {
|
||||||
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
|
|||||||
@ValidateIf((_, v) => v !== null)
|
@ValidateIf((_, v) => v !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RolePermissionPatchItemDto {
|
export class RolePermissionPatchItemDto {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type RoleRow = {
|
|||||||
color: string;
|
color: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
dataAccessPolicy?: unknown;
|
dataAccessPolicy?: unknown;
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
systemKey: string | null;
|
systemKey: string | null;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -79,6 +81,8 @@ export class OrgRolesService {
|
|||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
dataAccessPolicy?: DataAccessPolicy;
|
dataAccessPolicy?: DataAccessPolicy;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<OrgRoleDto> {
|
): Promise<OrgRoleDto> {
|
||||||
this.assertManager(actorRole);
|
this.assertManager(actorRole);
|
||||||
@@ -108,6 +112,8 @@ export class OrgRolesService {
|
|||||||
description: (dto.description ?? '').trim(),
|
description: (dto.description ?? '').trim(),
|
||||||
color: dto.color ?? parent?.color ?? 'teal',
|
color: dto.color ?? parent?.color ?? 'teal',
|
||||||
permissions,
|
permissions,
|
||||||
|
treasuryAccountIds: dto.treasuryAccountIds ?? [],
|
||||||
|
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
|
||||||
parentId,
|
parentId,
|
||||||
...(dto.dataAccessPolicy
|
...(dto.dataAccessPolicy
|
||||||
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
||||||
@@ -135,6 +141,8 @@ export class OrgRolesService {
|
|||||||
permissions?: RolePermissionKey[];
|
permissions?: RolePermissionKey[];
|
||||||
dataAccessPolicy?: DataAccessPolicy;
|
dataAccessPolicy?: DataAccessPolicy;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<OrgRoleDto> {
|
): Promise<OrgRoleDto> {
|
||||||
this.assertManager(actorRole);
|
this.assertManager(actorRole);
|
||||||
@@ -159,6 +167,12 @@ export class OrgRolesService {
|
|||||||
if (dto.dataAccessPolicy !== undefined) {
|
if (dto.dataAccessPolicy !== undefined) {
|
||||||
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
|
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
|
||||||
}
|
}
|
||||||
|
if (dto.treasuryAccountIds !== undefined) {
|
||||||
|
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
if (dto.treasuryOwnAccounts !== undefined) {
|
||||||
|
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.parentId !== undefined) {
|
if (dto.parentId !== undefined) {
|
||||||
const nextParentId =
|
const nextParentId =
|
||||||
@@ -426,6 +440,8 @@ export class OrgRolesService {
|
|||||||
color: row.color,
|
color: row.color,
|
||||||
permissions: sanitizePermissions(row.permissions),
|
permissions: sanitizePermissions(row.permissions),
|
||||||
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
||||||
|
treasuryAccountIds: row.treasuryAccountIds ?? [],
|
||||||
|
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
|
||||||
systemKey,
|
systemKey,
|
||||||
parentId: row.parentId,
|
parentId: row.parentId,
|
||||||
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
resolveOwnerId,
|
resolveOwnerId,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||||
|
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||||
|
|
||||||
const dealInclude = {
|
const dealInclude = {
|
||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
@@ -379,7 +380,8 @@ export class DealsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||||
const position =
|
let placedIds: string[] | null = null;
|
||||||
|
let position =
|
||||||
dto.position ??
|
dto.position ??
|
||||||
((
|
((
|
||||||
await this.prisma.deal.aggregate({
|
await this.prisma.deal.aggregate({
|
||||||
@@ -387,6 +389,17 @@ export class DealsService {
|
|||||||
_max: { position: true },
|
_max: { position: true },
|
||||||
})
|
})
|
||||||
)._max.position ?? -1) + 1;
|
)._max.position ?? -1) + 1;
|
||||||
|
if (dto.position != null) {
|
||||||
|
const rows = await this.prisma.deal.findMany({
|
||||||
|
where: { organizationId, stageId: stage.id },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||||
|
});
|
||||||
|
const currentIds = rows.map((row) => row.id);
|
||||||
|
placedIds = placeId(currentIds, deal.id, dto.position);
|
||||||
|
position = Math.max(0, placedIds.indexOf(deal.id));
|
||||||
|
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.prisma.deal.update({
|
const row = await this.prisma.deal.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -402,6 +415,15 @@ export class DealsService {
|
|||||||
include: dealInclude,
|
include: dealInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (placedIds) {
|
||||||
|
await writePositions(
|
||||||
|
(ops) => this.prisma.$transaction(ops),
|
||||||
|
placedIds,
|
||||||
|
(dealId, nextPosition) =>
|
||||||
|
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||||
await this.notifications.create({
|
await this.notifications.create({
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import {
|
||||||
|
parseUserTreasuryAccess,
|
||||||
|
treasurySectionAllowed,
|
||||||
|
type TreasurySectionKey,
|
||||||
|
} from '@mento/shared';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* حقوق فقط با treasury_payroll.
|
||||||
|
* بقیه مسیرها با treasury باز میشوند؛ اگر بخش ریز انتخاب شده باشد فقط همانها.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TreasuryAccessGuard implements CanActivate {
|
export class TreasuryAccessGuard implements CanActivate {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -10,20 +19,56 @@ export class TreasuryAccessGuard implements CanActivate {
|
|||||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||||
const actor = req.user;
|
const actor = req.user;
|
||||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||||
const url = req.originalUrl || req.url || '';
|
|
||||||
const payroll = url.includes('/treasury/payroll');
|
|
||||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||||
select: { orgRole: { select: { permissions: true } } },
|
select: {
|
||||||
|
orgRole: { select: { permissions: true } },
|
||||||
|
treasuryAccessOverride: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const permissions = Array.isArray(membership?.orgRole?.permissions)
|
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||||
? membership.orgRole.permissions.map(String)
|
? membership.orgRole.permissions.map(String)
|
||||||
: [];
|
: [];
|
||||||
const allowed = payroll
|
const permissions = permissionsForTreasury(
|
||||||
? permissions.includes('treasury_payroll')
|
rolePermissions,
|
||||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
);
|
||||||
|
const url = req.originalUrl || req.url || '';
|
||||||
|
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||||
|
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function permissionsForTreasury(
|
||||||
|
rolePermissions: string[],
|
||||||
|
override: ReturnType<typeof parseUserTreasuryAccess>,
|
||||||
|
): string[] {
|
||||||
|
if (!override) return rolePermissions;
|
||||||
|
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
|
||||||
|
if (!override.enabled) return rest;
|
||||||
|
return ['treasury', ...override.sections, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
|
||||||
|
if (url.includes('/treasury/payroll')) return 'treasury_payroll';
|
||||||
|
if (url.includes('/treasury/receivables') || url.includes('/treasury/installments')) {
|
||||||
|
return 'treasury_receivables';
|
||||||
|
}
|
||||||
|
if (url.includes('/treasury/recurring')) return 'treasury_recurring';
|
||||||
|
if (url.includes('/treasury/reports')) return 'treasury_reports';
|
||||||
|
if (url.includes('/treasury/audit')) return 'treasury_audit';
|
||||||
|
if (url.includes('/treasury/categories')) return 'treasury_categories';
|
||||||
|
if (
|
||||||
|
url.includes('/treasury/accounts') ||
|
||||||
|
url.includes('/treasury/transfers') ||
|
||||||
|
url.includes('/treasury/reconciliations') ||
|
||||||
|
url.includes('/treasury/adjustments')
|
||||||
|
) {
|
||||||
|
return 'treasury_accounts';
|
||||||
|
}
|
||||||
|
if (url.includes('/treasury/dashboard')) return 'treasury_dashboard';
|
||||||
|
return 'treasury_documents';
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,18 @@ export class TreasuryController {
|
|||||||
updateAccount(
|
updateAccount(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
@Body()
|
||||||
|
body: {
|
||||||
|
name?: string;
|
||||||
|
type?: 'bank' | 'cash' | 'petty';
|
||||||
|
bankName?: string | null;
|
||||||
|
accountRef?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
responsibleName?: string | null;
|
||||||
|
openingBalance?: string | number;
|
||||||
|
openingDate?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return this.treasury.updateAccount(user, id, body);
|
return this.treasury.updateAccount(user, id, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { parseUserTreasuryAccess } from '@mento/shared';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
@@ -129,8 +131,12 @@ export class TreasuryService {
|
|||||||
actor: Actor,
|
actor: Actor,
|
||||||
range?: { from?: string; to?: string },
|
range?: { from?: string; to?: string },
|
||||||
): Promise<FinancialAccountDto[]> {
|
): Promise<FinancialAccountDto[]> {
|
||||||
|
const scope = await this.accountScope(actor);
|
||||||
const accounts = await this.prisma.financialAccount.findMany({
|
const accounts = await this.prisma.financialAccount.findMany({
|
||||||
where: { organizationId: actor.organizationId },
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...(scope ? { id: { in: scope } } : {}),
|
||||||
|
},
|
||||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||||
});
|
});
|
||||||
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
||||||
@@ -196,19 +202,89 @@ export class TreasuryService {
|
|||||||
async updateAccount(
|
async updateAccount(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
id: string,
|
id: string,
|
||||||
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
input: {
|
||||||
|
name?: string;
|
||||||
|
type?: 'bank' | 'cash' | 'petty';
|
||||||
|
bankName?: string | null;
|
||||||
|
accountRef?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
responsibleName?: string | null;
|
||||||
|
openingBalance?: string | number;
|
||||||
|
openingDate?: string;
|
||||||
|
},
|
||||||
): Promise<FinancialAccountDto> {
|
): Promise<FinancialAccountDto> {
|
||||||
const existing = await this.requireAccount(actor.organizationId, id);
|
const existing = await this.requireAccount(actor.organizationId, id, actor);
|
||||||
const row = await this.prisma.financialAccount.update({
|
if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
|
||||||
where: { id: existing.id },
|
throw new BadRequestException('نوع حساب نامعتبر است.');
|
||||||
data: {
|
}
|
||||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
|
||||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
if (opening?.isNegative()) {
|
||||||
...(input.responsibleName !== undefined
|
throw new BadRequestException('موجودی اولیه نمیتواند منفی باشد.');
|
||||||
? { responsibleName: input.responsibleName?.trim() || null }
|
}
|
||||||
: {}),
|
const openingDate =
|
||||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
input.openingDate !== undefined
|
||||||
},
|
? (startOfDay(input.openingDate) ?? existing.openingDate)
|
||||||
|
: null;
|
||||||
|
const row = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.financialAccount.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||||
|
...(input.type ? { type: input.type } : {}),
|
||||||
|
...(input.bankName !== undefined ? { bankName: input.bankName?.trim() || null } : {}),
|
||||||
|
...(input.accountRef !== undefined ? { accountRef: input.accountRef?.trim() || null } : {}),
|
||||||
|
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||||
|
...(input.responsibleName !== undefined
|
||||||
|
? { responsibleName: input.responsibleName?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||||
|
...(opening ? { openingBalance: opening } : {}),
|
||||||
|
...(openingDate ? { openingDate } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (opening || openingDate) {
|
||||||
|
const nextAmount = opening ?? existing.openingBalance;
|
||||||
|
const nextDate = openingDate ?? existing.openingDate;
|
||||||
|
const openings = await tx.financialMovement.findMany({
|
||||||
|
where: { accountId: existing.id, kind: 'opening', voidedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
if (nextAmount.isZero()) {
|
||||||
|
if (openings.length) {
|
||||||
|
await tx.financialMovement.updateMany({
|
||||||
|
where: { id: { in: openings.map((row) => row.id) } },
|
||||||
|
data: { voidedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (openings[0]) {
|
||||||
|
await tx.financialMovement.update({
|
||||||
|
where: { id: openings[0].id },
|
||||||
|
data: { amount: nextAmount, occurredAt: nextDate, direction: 'in' },
|
||||||
|
});
|
||||||
|
const extra = openings.slice(1).map((row) => row.id);
|
||||||
|
if (extra.length) {
|
||||||
|
await tx.financialMovement.updateMany({
|
||||||
|
where: { id: { in: extra } },
|
||||||
|
data: { voidedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tx.financialMovement.create({
|
||||||
|
data: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
accountId: existing.id,
|
||||||
|
direction: 'in',
|
||||||
|
amount: nextAmount,
|
||||||
|
kind: 'opening',
|
||||||
|
occurredAt: nextDate,
|
||||||
|
memo: 'مانده افتتاحیه',
|
||||||
|
createdById: actor.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
return this.toAccountDto(row);
|
return this.toAccountDto(row);
|
||||||
}
|
}
|
||||||
@@ -218,7 +294,7 @@ export class TreasuryService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
range?: { from?: string; to?: string },
|
range?: { from?: string; to?: string },
|
||||||
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
|
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
|
||||||
const account = await this.requireAccount(actor.organizationId, accountId);
|
const account = await this.requireAccount(actor.organizationId, accountId, actor);
|
||||||
const from = startOfDay(range?.from);
|
const from = startOfDay(range?.from);
|
||||||
const to = startOfDay(range?.to);
|
const to = startOfDay(range?.to);
|
||||||
const rows = await this.prisma.financialMovement.findMany({
|
const rows = await this.prisma.financialMovement.findMany({
|
||||||
@@ -303,8 +379,8 @@ export class TreasuryService {
|
|||||||
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
||||||
}
|
}
|
||||||
const [source, target] = await Promise.all([
|
const [source, target] = await Promise.all([
|
||||||
this.requireAccount(actor.organizationId, input.fromAccountId),
|
this.requireAccount(actor.organizationId, input.fromAccountId, actor),
|
||||||
this.requireAccount(actor.organizationId, input.toAccountId),
|
this.requireAccount(actor.organizationId, input.toAccountId, actor),
|
||||||
]);
|
]);
|
||||||
if (!source.isActive || !target.isActive) {
|
if (!source.isActive || !target.isActive) {
|
||||||
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
||||||
@@ -354,10 +430,12 @@ export class TreasuryService {
|
|||||||
kind: 'income' | 'expense',
|
kind: 'income' | 'expense',
|
||||||
q?: string,
|
q?: string,
|
||||||
): Promise<FinancialDocumentDto[]> {
|
): Promise<FinancialDocumentDto[]> {
|
||||||
|
const scope = await this.accountScope(actor);
|
||||||
const rows = await this.prisma.financialDocument.findMany({
|
const rows = await this.prisma.financialDocument.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
kind,
|
kind,
|
||||||
|
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||||
...(q?.trim()
|
...(q?.trim()
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -408,7 +486,7 @@ export class TreasuryService {
|
|||||||
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
||||||
}
|
}
|
||||||
if (input.accountId) {
|
if (input.accountId) {
|
||||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||||
if (input.kind === 'expense' && settle.greaterThan(0)) {
|
if (input.kind === 'expense' && settle.greaterThan(0)) {
|
||||||
const balance = await this.accountBalance(account.id);
|
const balance = await this.accountBalance(account.id);
|
||||||
@@ -482,7 +560,7 @@ export class TreasuryService {
|
|||||||
if (amount.greaterThan(remaining)) {
|
if (amount.greaterThan(remaining)) {
|
||||||
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
|
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
|
||||||
}
|
}
|
||||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||||
if (doc.kind === 'expense') {
|
if (doc.kind === 'expense') {
|
||||||
const balance = await this.accountBalance(account.id);
|
const balance = await this.accountBalance(account.id);
|
||||||
@@ -644,9 +722,14 @@ export class TreasuryService {
|
|||||||
): Promise<TreasuryDashboardDto> {
|
): Promise<TreasuryDashboardDto> {
|
||||||
const from = startOfDay(range?.from);
|
const from = startOfDay(range?.from);
|
||||||
const to = startOfDay(range?.to);
|
const to = startOfDay(range?.to);
|
||||||
|
const scope = await this.accountScope(actor);
|
||||||
|
const accountFilter = scope
|
||||||
|
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
|
||||||
|
: {};
|
||||||
const bookedWhere = {
|
const bookedWhere = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
status: { not: 'void' },
|
status: { not: 'void' },
|
||||||
|
...accountFilter,
|
||||||
...(from || to
|
...(from || to
|
||||||
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -654,6 +737,7 @@ export class TreasuryService {
|
|||||||
const cashWhere = {
|
const cashWhere = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
|
...(scope ? { accountId: { in: scope } } : {}),
|
||||||
...(from || to
|
...(from || to
|
||||||
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -680,12 +764,13 @@ export class TreasuryService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
status: { in: ['open', 'partial'] },
|
status: { in: ['open', 'partial'] },
|
||||||
|
...accountFilter,
|
||||||
},
|
},
|
||||||
include: { movements: true },
|
include: { movements: true },
|
||||||
}),
|
}),
|
||||||
this.listAccounts(actor),
|
this.listAccounts(actor),
|
||||||
this.prisma.financialDocument.findMany({
|
this.prisma.financialDocument.findMany({
|
||||||
where: { organizationId: actor.organizationId },
|
where: { organizationId: actor.organizationId, ...accountFilter },
|
||||||
include: { category: true, account: true, movements: true },
|
include: { category: true, account: true, movements: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 8,
|
take: 8,
|
||||||
@@ -754,11 +839,50 @@ export class TreasuryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireAccount(organizationId: string, id: string) {
|
private async accountScope(actor: Actor): Promise<string[] | null> {
|
||||||
|
if (actor.role === 'owner' || actor.role === 'admin') return null;
|
||||||
|
const membership = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||||
|
select: {
|
||||||
|
user: { select: { fullName: true } },
|
||||||
|
orgRole: { select: { treasuryAccountIds: true, treasuryOwnAccounts: true } },
|
||||||
|
treasuryAccessOverride: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const override = parseUserTreasuryAccess(membership?.treasuryAccessOverride);
|
||||||
|
if (override && !override.enabled) return [];
|
||||||
|
const extra = override
|
||||||
|
? override.accountIds
|
||||||
|
: (membership?.orgRole?.treasuryAccountIds ?? []);
|
||||||
|
const own = override
|
||||||
|
? override.ownAccounts
|
||||||
|
: Boolean(membership?.orgRole?.treasuryOwnAccounts);
|
||||||
|
if (!own && extra.length === 0) return null;
|
||||||
|
const ids = new Set(extra);
|
||||||
|
const name = membership?.user.fullName?.trim();
|
||||||
|
if (own && name) {
|
||||||
|
const rows = await this.prisma.financialAccount.findMany({
|
||||||
|
where: { organizationId: actor.organizationId, responsibleName: { not: null } },
|
||||||
|
select: { id: true, responsibleName: true },
|
||||||
|
});
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.responsibleName?.trim() === name) ids.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAccount(organizationId: string, id: string, actor?: Actor) {
|
||||||
const row = await this.prisma.financialAccount.findFirst({
|
const row = await this.prisma.financialAccount.findFirst({
|
||||||
where: { id, organizationId },
|
where: { id, organizationId },
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
||||||
|
if (actor) {
|
||||||
|
const scope = await this.accountScope(actor);
|
||||||
|
if (scope && !scope.includes(row.id)) {
|
||||||
|
throw new ForbiddenException('به این حساب دسترسی ندارید.');
|
||||||
|
}
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
dataAccessOverride?: Record<string, unknown> | null;
|
dataAccessOverride?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
/** null = ارث از نقش */
|
||||||
|
@ValidateIf((_, v) => v !== null)
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import type {
|
|||||||
UpdateUserVisibilityRequest,
|
UpdateUserVisibilityRequest,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
|
import {
|
||||||
|
DATA_SCOPES,
|
||||||
|
DATA_VISIBILITIES,
|
||||||
|
parseDataAccessPolicy,
|
||||||
|
parseUserTreasuryAccess,
|
||||||
|
} from '@mento/shared';
|
||||||
import { resolveAllSectionAccess } from '../common/data-access';
|
import { resolveAllSectionAccess } from '../common/data-access';
|
||||||
import { parseVisibilityScopes } from '../common/record-scope';
|
import { parseVisibilityScopes } from '../common/record-scope';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -47,6 +52,7 @@ type MembershipRow = {
|
|||||||
dataVisibility?: string | null;
|
dataVisibility?: string | null;
|
||||||
dataVisibilityScopes?: unknown;
|
dataVisibilityScopes?: unknown;
|
||||||
dataAccessOverride?: unknown;
|
dataAccessOverride?: unknown;
|
||||||
|
treasuryAccessOverride?: unknown;
|
||||||
priorArchiveEnabled?: boolean;
|
priorArchiveEnabled?: boolean;
|
||||||
logoutTime?: string | null;
|
logoutTime?: string | null;
|
||||||
blockLoginAfterLogout?: boolean;
|
blockLoginAfterLogout?: boolean;
|
||||||
@@ -370,23 +376,35 @@ export class UsersService {
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
actorRole: UserRole,
|
actorRole: UserRole,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
dto: { dataAccessOverride?: Record<string, unknown> | null },
|
dto: {
|
||||||
|
dataAccessOverride?: Record<string, unknown> | null;
|
||||||
|
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||||
|
},
|
||||||
): Promise<OrgUserDto> {
|
): Promise<OrgUserDto> {
|
||||||
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
||||||
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
|
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
|
||||||
}
|
}
|
||||||
if (dto.dataAccessOverride === undefined) {
|
if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
|
||||||
throw new BadRequestException('تغییری ارسال نشده است');
|
throw new BadRequestException('تغییری ارسال نشده است');
|
||||||
}
|
}
|
||||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||||
if (target.role === 'owner' || target.role === 'admin') {
|
if (target.role === 'owner' || target.role === 'admin') {
|
||||||
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
|
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
|
||||||
}
|
}
|
||||||
const next =
|
const data: Prisma.MembershipUpdateInput = {};
|
||||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
if (dto.dataAccessOverride !== undefined) {
|
||||||
|
data.dataAccessOverride =
|
||||||
|
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||||
|
}
|
||||||
|
if (dto.treasuryAccessOverride !== undefined) {
|
||||||
|
data.treasuryAccessOverride =
|
||||||
|
dto.treasuryAccessOverride === null
|
||||||
|
? Prisma.DbNull
|
||||||
|
: (parseUserTreasuryAccess(dto.treasuryAccessOverride) ?? Prisma.DbNull);
|
||||||
|
}
|
||||||
await this.prisma.membership.update({
|
await this.prisma.membership.update({
|
||||||
where: { id: target.id },
|
where: { id: target.id },
|
||||||
data: { dataAccessOverride: next },
|
data,
|
||||||
});
|
});
|
||||||
return this.dtoOf(organizationId, targetId);
|
return this.dtoOf(organizationId, targetId);
|
||||||
}
|
}
|
||||||
@@ -606,6 +624,10 @@ export class UsersService {
|
|||||||
row.role === 'owner' || row.role === 'admin'
|
row.role === 'owner' || row.role === 'admin'
|
||||||
? {}
|
? {}
|
||||||
: parseDataAccessPolicy(row.dataAccessOverride),
|
: parseDataAccessPolicy(row.dataAccessOverride),
|
||||||
|
treasuryAccessOverride:
|
||||||
|
row.role === 'owner' || row.role === 'admin'
|
||||||
|
? null
|
||||||
|
: parseUserTreasuryAccess(row.treasuryAccessOverride),
|
||||||
...(() => {
|
...(() => {
|
||||||
const effectiveDataAccess = resolveAllSectionAccess({
|
const effectiveDataAccess = resolveAllSectionAccess({
|
||||||
role: row.role,
|
role: row.role,
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { Tabs as ATabs, TabPane as ATabPane, Typography } from 'ant-design-vue';
|
import {
|
||||||
|
Tabs as ATabs,
|
||||||
|
TabPane as ATabPane,
|
||||||
|
Typography,
|
||||||
|
Radio as ARadio,
|
||||||
|
RadioGroup as ARadioGroup,
|
||||||
|
Checkbox as ACheckbox,
|
||||||
|
Space as ASpace,
|
||||||
|
Input as AInput,
|
||||||
|
} from 'ant-design-vue';
|
||||||
import type {
|
import type {
|
||||||
DataAccessPolicy,
|
DataAccessPolicy,
|
||||||
DataAccessSection,
|
DataAccessSection,
|
||||||
|
FinancialAccountDto,
|
||||||
PersonImportSourceStatDto,
|
PersonImportSourceStatDto,
|
||||||
RolePermissionKey,
|
RolePermissionKey,
|
||||||
SectionDataAccess,
|
SectionDataAccess,
|
||||||
|
UserTreasuryAccess,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
DATA_ACCESS_SECTION_LABELS,
|
DATA_ACCESS_SECTION_LABELS,
|
||||||
DATA_ACCESS_SECTIONS,
|
DATA_ACCESS_SECTIONS,
|
||||||
|
ROLE_PERMISSION_ITEMS,
|
||||||
emptySectionDataAccess,
|
emptySectionDataAccess,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -24,11 +36,16 @@ const props = defineProps<{
|
|||||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||||
enabledPermissions?: RolePermissionKey[];
|
enabledPermissions?: RolePermissionKey[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** تبهای عمق داده قفل شوند؛ تب مدیریت مالی جدا میماند */
|
||||||
|
dataLocked?: boolean;
|
||||||
|
showTreasury?: boolean;
|
||||||
|
treasuryAccess?: UserTreasuryAccess | null;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [DataAccessPolicy];
|
'update:modelValue': [DataAccessPolicy];
|
||||||
|
'update:treasuryAccess': [UserTreasuryAccess | null];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
||||||
@@ -50,12 +67,13 @@ const visibleSections = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = ref<DataAccessSection>('customers');
|
const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
|
||||||
|
const active = ref<DataAccessSection | 'treasury'>('customers');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
visibleSections,
|
visibleSections,
|
||||||
(list) => {
|
(list) => {
|
||||||
if (!list.includes(active.value) && list[0]) active.value = list[0];
|
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -92,6 +110,52 @@ const activityTypes = ref<IdOption[]>([]);
|
|||||||
const projectBoards = ref<IdOption[]>([]);
|
const projectBoards = ref<IdOption[]>([]);
|
||||||
const projectStages = ref<IdOption[]>([]);
|
const projectStages = ref<IdOption[]>([]);
|
||||||
const routineCategories = ref<IdOption[]>([]);
|
const routineCategories = ref<IdOption[]>([]);
|
||||||
|
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
|
||||||
|
const treasuryAccountQuery = ref('');
|
||||||
|
|
||||||
|
const filteredTreasuryAccounts = computed(() => {
|
||||||
|
const query = treasuryAccountQuery.value.trim();
|
||||||
|
if (!query) return treasuryAccounts.value;
|
||||||
|
return treasuryAccounts.value.filter((account) => account.name.includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
|
function emptyTreasuryAccess(): UserTreasuryAccess {
|
||||||
|
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTreasury(next: UserTreasuryAccess | null) {
|
||||||
|
emit('update:treasuryAccess', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
|
||||||
|
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreasurySection(key: RolePermissionKey) {
|
||||||
|
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||||
|
const sections = current.sections.includes(key)
|
||||||
|
? current.sections.filter((item) => item !== key)
|
||||||
|
: [...current.sections, key];
|
||||||
|
patchTreasury({ ...current, enabled: true, sections });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreasuryAccount(id: string) {
|
||||||
|
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||||
|
const accountIds = current.accountIds.includes(id)
|
||||||
|
? current.accountIds.filter((item) => item !== id)
|
||||||
|
: [...current.accountIds, id];
|
||||||
|
patchTreasury({ ...current, enabled: true, accountIds });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTreasuryAccounts() {
|
||||||
|
if (!props.showTreasury) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||||
|
treasuryAccounts.value = data;
|
||||||
|
} catch {
|
||||||
|
treasuryAccounts.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
await loadCustomerGroups().catch(() => undefined);
|
await loadCustomerGroups().catch(() => undefined);
|
||||||
@@ -179,6 +243,7 @@ async function loadOptions() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
|
void loadTreasuryAccounts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -204,11 +269,81 @@ onMounted(() => {
|
|||||||
:import-sources="importSources"
|
:import-sources="importSources"
|
||||||
:import-sources-ready="importSourcesReady"
|
:import-sources-ready="importSourcesReady"
|
||||||
:import-sources-failed="importSourcesFailed"
|
:import-sources-failed="importSourcesFailed"
|
||||||
:disabled="disabled"
|
:disabled="disabled || dataLocked"
|
||||||
:compact="compact"
|
:compact="compact"
|
||||||
@update:model-value="setSection(s, $event)"
|
@update:model-value="setSection(s, $event)"
|
||||||
/>
|
/>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
|
||||||
|
<a-space direction="vertical">
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
||||||
|
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
||||||
|
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
||||||
|
</a-typography-text>
|
||||||
|
<a-radio-group
|
||||||
|
:value="treasuryAccess == null ? 'role' : 'custom'"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="onTreasuryModeChange"
|
||||||
|
>
|
||||||
|
<a-space direction="vertical">
|
||||||
|
<a-radio value="role">مثل نقش</a-radio>
|
||||||
|
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
|
||||||
|
</a-space>
|
||||||
|
</a-radio-group>
|
||||||
|
<template v-if="treasuryAccess">
|
||||||
|
<a-checkbox
|
||||||
|
:checked="treasuryAccess.enabled"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
||||||
|
>
|
||||||
|
دسترسی به مدیریت مالی
|
||||||
|
</a-checkbox>
|
||||||
|
<template v-if="treasuryAccess.enabled">
|
||||||
|
<a-space direction="vertical">
|
||||||
|
<a-checkbox
|
||||||
|
v-for="perm in treasurySections"
|
||||||
|
:key="perm.key"
|
||||||
|
:checked="treasuryAccess.sections.includes(perm.key)"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="toggleTreasurySection(perm.key)"
|
||||||
|
>
|
||||||
|
{{ perm.label }}
|
||||||
|
</a-checkbox>
|
||||||
|
</a-space>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="treasuryAccess.ownAccounts"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
|
||||||
|
>
|
||||||
|
حسابهایی که خودش مسئول آنهاست
|
||||||
|
</a-checkbox>
|
||||||
|
<a-typography-text type="secondary">حسابهای دیگر</a-typography-text>
|
||||||
|
<a-input
|
||||||
|
v-model:value="treasuryAccountQuery"
|
||||||
|
placeholder="جستجوی حساب"
|
||||||
|
:disabled="disabled"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-space direction="vertical">
|
||||||
|
<a-checkbox
|
||||||
|
v-for="account in filteredTreasuryAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
:checked="treasuryAccess.accountIds.includes(account.id)"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="toggleTreasuryAccount(account.id)"
|
||||||
|
>
|
||||||
|
{{ account.name }}
|
||||||
|
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||||
|
</a-checkbox>
|
||||||
|
</a-space>
|
||||||
|
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
|
||||||
|
حسابی برای انتخاب نیست.
|
||||||
|
</a-typography-text>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-space>
|
||||||
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
TeamDto,
|
TeamDto,
|
||||||
TeamMemberRole,
|
TeamMemberRole,
|
||||||
UpdatePriorArchiveCutoffRequest,
|
UpdatePriorArchiveCutoffRequest,
|
||||||
|
UserTreasuryAccess,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
DATA_ACCESS_SECTION_LABELS,
|
DATA_ACCESS_SECTION_LABELS,
|
||||||
@@ -334,8 +335,13 @@ async function removeTeam(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showTreasury = computed(() => {
|
||||||
|
const modules = auth.user?.enabledModules;
|
||||||
|
return !modules || modules.includes('treasury');
|
||||||
|
});
|
||||||
const expandedAccess = ref<string | null>(null);
|
const expandedAccess = ref<string | null>(null);
|
||||||
const draftAccess = ref<DataAccessPolicy>({});
|
const draftAccess = ref<DataAccessPolicy>({});
|
||||||
|
const draftTreasury = ref<UserTreasuryAccess | null>(null);
|
||||||
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
||||||
const accessSaving = ref(false);
|
const accessSaving = ref(false);
|
||||||
|
|
||||||
@@ -378,6 +384,7 @@ function openAccessEditor(u: OrgUserDto) {
|
|||||||
draftAccess.value = hasOverride
|
draftAccess.value = hasOverride
|
||||||
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
||||||
: clonePolicy(u.effectiveDataAccess);
|
: clonePolicy(u.effectiveDataAccess);
|
||||||
|
draftTreasury.value = u.treasuryAccessOverride ?? null;
|
||||||
expandedAccess.value = u.id;
|
expandedAccess.value = u.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,8 +395,8 @@ async function saveUserAccess(userId: string) {
|
|||||||
try {
|
try {
|
||||||
const payload =
|
const payload =
|
||||||
accessMode.value === 'inherit'
|
accessMode.value === 'inherit'
|
||||||
? { dataAccessOverride: null }
|
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
|
||||||
: { dataAccessOverride: draftAccess.value };
|
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
|
||||||
await api.patch(`/users/${userId}/data-access`, payload);
|
await api.patch(`/users/${userId}/data-access`, payload);
|
||||||
ok.value = 'محدوده داده بهروز شد.';
|
ok.value = 'محدوده داده بهروز شد.';
|
||||||
expandedAccess.value = null;
|
expandedAccess.value = null;
|
||||||
@@ -585,7 +592,7 @@ onMounted(() => {
|
|||||||
<a-collapse-panel key="access" header="محدوده داده کاربران">
|
<a-collapse-panel key="access" header="محدوده داده کاربران">
|
||||||
<a-flex vertical :gap="8">
|
<a-flex vertical :gap="8">
|
||||||
<a-typography-text type="secondary">
|
<a-typography-text type="secondary">
|
||||||
پیشفرض از نقش است؛ برای کاربر خاص میتوانید فیلتر جدا بگذارید.
|
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر جدا بگذارید.
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
<a-empty
|
<a-empty
|
||||||
v-if="!editableUsers.length"
|
v-if="!editableUsers.length"
|
||||||
@@ -612,8 +619,10 @@ onMounted(() => {
|
|||||||
</a-space>
|
</a-space>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<DataAccessPolicyPanel
|
<DataAccessPolicyPanel
|
||||||
v-if="accessMode === 'custom'"
|
|
||||||
v-model="draftAccess"
|
v-model="draftAccess"
|
||||||
|
v-model:treasury-access="draftTreasury"
|
||||||
|
:show-treasury="showTreasury"
|
||||||
|
:data-locked="accessMode === 'inherit'"
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
|||||||
assigneeNames,
|
assigneeNames,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,40 +255,54 @@ 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 || fromStageId === toStageId) 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 (!to) return;
|
||||||
const item = from.items.find((i) => i.id === activityId)!;
|
const fromIndex = from.items.findIndex((i) => i.id === activityId);
|
||||||
|
const item = from.items[fromIndex];
|
||||||
|
if (!item) return;
|
||||||
const toStage = props.stages.find((s) => s.id === toStageId)!;
|
const toStage = props.stages.find((s) => s.id === toStageId)!;
|
||||||
|
const same = fromStageId === toStageId;
|
||||||
|
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
|
||||||
|
if (same && index == null) return;
|
||||||
|
let insertAt = index == null || index < 0 ? 0 : index;
|
||||||
|
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||||
|
if (same && insertAt === fromIndex) return;
|
||||||
|
|
||||||
const snapshot = {
|
const prevFrom = from.items;
|
||||||
fromItems: from.items,
|
const prevTo = to.items;
|
||||||
fromTotal: from.total,
|
const prevFromTotal = from.total;
|
||||||
toItems: to.items,
|
const prevToTotal = to.total;
|
||||||
toTotal: to.total,
|
|
||||||
};
|
|
||||||
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
|
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
|
||||||
from.items = from.items.filter((i) => i.id !== activityId);
|
const nextTo = [...without];
|
||||||
from.total = Math.max(0, from.total - 1);
|
nextTo.splice(insertAt, 0, optimistic);
|
||||||
const nextTo = [...to.items];
|
if (same) {
|
||||||
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
|
from.items = nextTo;
|
||||||
nextTo.splice(at, 0, optimistic);
|
} else {
|
||||||
to.items = nextTo;
|
from.items = from.items.filter((i) => i.id !== activityId);
|
||||||
to.total += 1;
|
from.total = Math.max(0, from.total - 1);
|
||||||
|
to.items = nextTo;
|
||||||
|
to.total += 1;
|
||||||
|
}
|
||||||
|
|
||||||
moving.value = true;
|
moving.value = true;
|
||||||
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,
|
||||||
});
|
});
|
||||||
to.items = to.items.map((i) => (i.id === activityId ? data : i));
|
const column = same ? from : to;
|
||||||
|
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
||||||
emit('changed');
|
emit('changed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
from.items = snapshot.fromItems;
|
from.items = prevFrom;
|
||||||
from.total = snapshot.fromTotal;
|
from.total = prevFromTotal;
|
||||||
to.items = snapshot.toItems;
|
if (!same) {
|
||||||
to.total = snapshot.toTotal;
|
to.items = prevTo;
|
||||||
|
to.total = prevToTotal;
|
||||||
|
}
|
||||||
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
||||||
} finally {
|
} finally {
|
||||||
moving.value = false;
|
moving.value = false;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
FlagFilled,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +14,12 @@ import {
|
|||||||
Flex as AFlex,
|
Flex as AFlex,
|
||||||
Space as ASpace,
|
Space as ASpace,
|
||||||
Tag as ATag,
|
Tag as ATag,
|
||||||
|
Tooltip as ATooltip,
|
||||||
Typography,
|
Typography,
|
||||||
theme,
|
theme,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
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';
|
||||||
|
|
||||||
@@ -60,6 +63,16 @@ 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 }> = {
|
||||||
|
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
||||||
|
high: { label: 'زیاد', color: '#ea580c' },
|
||||||
|
medium: { label: 'متوسط', color: '#eab308' },
|
||||||
|
low: { label: 'کم', color: '#16a34a' },
|
||||||
|
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
||||||
|
};
|
||||||
|
const priorityFlag = computed(() =>
|
||||||
|
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
||||||
|
);
|
||||||
|
|
||||||
const cardStyle = computed(() => ({
|
const cardStyle = computed(() => ({
|
||||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||||
@@ -194,6 +207,11 @@ const cardStyle = computed(() => ({
|
|||||||
{{ money.unit }}
|
{{ money.unit }}
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
|
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||||
|
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||||
|
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import AddDealButton from './AddDealButton.vue';
|
import AddDealButton from './AddDealButton.vue';
|
||||||
import DealCard from './DealCard.vue';
|
import DealCard from './DealCard.vue';
|
||||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -115,7 +115,7 @@ function onCardDragOver(stageId: string, event: DragEvent) {
|
|||||||
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
emit('drop', stageId, event, index);
|
emit('drop', stageId, event, pointerDropIndex(index, event));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename() {
|
function startRename() {
|
||||||
|
|||||||
@@ -3,30 +3,30 @@ import { computed } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Menu as AMenu } from 'ant-design-vue';
|
import { Menu as AMenu } from 'ant-design-vue';
|
||||||
import type { MenuProps } from 'ant-design-vue';
|
import type { MenuProps } from 'ant-design-vue';
|
||||||
|
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const canPayroll = computed(
|
|
||||||
() =>
|
|
||||||
auth.user?.role === 'owner' ||
|
|
||||||
auth.user?.role === 'admin' ||
|
|
||||||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
|
||||||
);
|
|
||||||
|
|
||||||
const links = computed(() => [
|
const links = computed(() => {
|
||||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
const role = auth.user?.role;
|
||||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
const permissions = auth.user?.permissions;
|
||||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
|
||||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
{ to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
|
||||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
{ to: '/finance/incomes', label: 'دریافتها و درآمدها', section: 'treasury_incomes' },
|
||||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
{ to: '/finance/expenses', label: 'پرداختها و هزینهها', section: 'treasury_expenses' },
|
||||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
{ to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
|
||||||
{ to: '/finance/reports', label: 'گزارش' },
|
{ to: '/finance/accounts', label: 'صندوق و حسابها', section: 'treasury_accounts' },
|
||||||
{ to: '/finance/audit', label: 'سوابق' },
|
{ to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
|
||||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||||
]);
|
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||||
|
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||||
|
{ to: '/finance/categories', label: 'دستهبندیها', section: 'treasury_categories' },
|
||||||
|
];
|
||||||
|
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||||
|
});
|
||||||
|
|
||||||
function on(path: string, exact?: boolean) {
|
function on(path: string, exact?: boolean) {
|
||||||
if (exact) return route.path === path;
|
if (exact) return route.path === path;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
NotificationsListDto,
|
NotificationsListDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||||
|
import { hasSectionPermission, sectionPermissionForPath } from '@/lib/section-permissions';
|
||||||
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -167,12 +168,24 @@ function cardAllowedForNav(card: {
|
|||||||
to?: string;
|
to?: string;
|
||||||
}) {
|
}) {
|
||||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||||
|
const perms = auth.user?.permissions ?? [];
|
||||||
const canTreasury = auth.user?.role === 'owner'
|
const canTreasury = auth.user?.role === 'owner'
|
||||||
|| auth.user?.role === 'admin'
|
|| auth.user?.role === 'admin'
|
||||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
|| perms.includes('treasury')
|
||||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
|| perms.some((key) => key.startsWith('treasury_'));
|
||||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
if (card.to?.startsWith('/finance')) {
|
||||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
|
||||||
|
}
|
||||||
|
const section = card.to ? sectionPermissionForPath(card.to) : null;
|
||||||
|
const hasSection = section
|
||||||
|
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
|
||||||
|
: true;
|
||||||
|
if (!hasSection) return false;
|
||||||
|
// لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز میشود
|
||||||
|
const grantedBySection = section === 'audit' && hasSection;
|
||||||
|
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!moduleAllowed(card.module)) return false;
|
if (!moduleAllowed(card.module)) return false;
|
||||||
if (
|
if (
|
||||||
card.admin &&
|
card.admin &&
|
||||||
|
|||||||
@@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
|
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
|
||||||
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
|
return (
|
||||||
|
file.mime.startsWith('image/') ||
|
||||||
|
(file.dataUrl ?? '').startsWith('data:image/') ||
|
||||||
|
/\.(png|jpe?g|gif|webp)$/i.test(file.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NoteChip =
|
||||||
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'image'; src: string; name: string }
|
||||||
|
| { kind: 'link'; href: string; label: string };
|
||||||
|
|
||||||
|
const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi;
|
||||||
|
const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi;
|
||||||
|
const HTTP_URL = /https?:\/\/[^\s<>"']+/gi;
|
||||||
|
|
||||||
|
/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا میکند. */
|
||||||
|
export function noteChips(raw: string | null | undefined): NoteChip[] {
|
||||||
|
const text = raw ?? '';
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
const chips: NoteChip[] = [];
|
||||||
|
const token = new RegExp(
|
||||||
|
`${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
let cursor = 0;
|
||||||
|
for (const match of text.matchAll(token)) {
|
||||||
|
const index = match.index ?? 0;
|
||||||
|
pushText(chips, text.slice(cursor, index));
|
||||||
|
chips.push(chipForToken(match[0]));
|
||||||
|
cursor = index + match[0].length;
|
||||||
|
}
|
||||||
|
pushText(chips, text.slice(cursor));
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushText(chips: NoteChip[], value: string) {
|
||||||
|
if (!value) return;
|
||||||
|
const prev = chips[chips.length - 1];
|
||||||
|
if (prev?.kind === 'text') prev.text += value;
|
||||||
|
else chips.push({ kind: 'text', text: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipForToken(token: string): NoteChip {
|
||||||
|
if (token.startsWith('{')) {
|
||||||
|
const file = readFileValue(token);
|
||||||
|
if (file?.dataUrl && leadNoteFileIsImage(file)) {
|
||||||
|
return { kind: 'image', src: file.dataUrl, name: file.name };
|
||||||
|
}
|
||||||
|
if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name };
|
||||||
|
return { kind: 'text', text: file?.name || 'فایل پیوست' };
|
||||||
|
}
|
||||||
|
if (token.startsWith('data:image/')) {
|
||||||
|
return { kind: 'image', src: token, name: 'تصویر پیوست' };
|
||||||
|
}
|
||||||
|
if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) };
|
||||||
|
return { kind: 'link', href: token, label: shortUrl(token) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageUrl(href: string): boolean {
|
||||||
|
return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUrl(href: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? '');
|
||||||
|
const label = file || url.hostname;
|
||||||
|
return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label;
|
||||||
|
} catch {
|
||||||
|
return href.length > 42 ? `${href.slice(0, 28)}…` : href;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
|
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
|
||||||
@@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null {
|
|||||||
return asFile(JSON.parse(value));
|
return asFile(JSON.parse(value));
|
||||||
} catch {
|
} catch {
|
||||||
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
|
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
|
||||||
if (name) return { name, mime: '', dataUrl: null };
|
const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? '';
|
||||||
|
if (name) return { name, mime, dataUrl: null };
|
||||||
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
|
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
|
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ export type PipelineDeal = {
|
|||||||
assigneeIds?: string[];
|
assigneeIds?: string[];
|
||||||
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
||||||
searchFields?: Array<string | null | undefined>;
|
searchFields?: Array<string | null | undefined>;
|
||||||
|
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده میشود */
|
||||||
|
priority?: ActivityPriority;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PipelineUiCopy = {
|
export type PipelineUiCopy = {
|
||||||
@@ -344,18 +347,16 @@ export function updateDealStage(
|
|||||||
return without.map((stage) => {
|
return without.map((stage) => {
|
||||||
if (stage.id !== stageId) return stage;
|
if (stage.id !== stageId) return stage;
|
||||||
const next = [...stage.deals];
|
const next = [...stage.deals];
|
||||||
let insertAt =
|
let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex;
|
||||||
toIndex == null || toIndex < 0 || toIndex > next.length
|
|
||||||
? next.length
|
|
||||||
: toIndex;
|
|
||||||
if (
|
if (
|
||||||
fromStageId === stageId &&
|
fromStageId === stageId &&
|
||||||
fromIndex >= 0 &&
|
fromIndex >= 0 &&
|
||||||
toIndex != null &&
|
toIndex != null &&
|
||||||
toIndex > fromIndex
|
toIndex > fromIndex
|
||||||
) {
|
) {
|
||||||
insertAt = Math.max(0, insertAt - 1);
|
insertAt -= 1;
|
||||||
}
|
}
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, next.length));
|
||||||
next.splice(insertAt, 0, { ...moving!, stageId });
|
next.splice(insertAt, 0, { ...moving!, stageId });
|
||||||
return { ...stage, deals: next };
|
return { ...stage, deals: next };
|
||||||
});
|
});
|
||||||
@@ -422,6 +423,14 @@ export function toFaDealCount(n: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** جابهجایی یک ستون به ایندکس جدید */
|
/** جابهجایی یک ستون به ایندکس جدید */
|
||||||
|
/** نیمهٔ پایین کارت یعنی «بعد از این کارت». ایندکس نسبت به فهرست فعلی ستون است. */
|
||||||
|
export function pointerDropIndex(index: number, event: DragEvent): number {
|
||||||
|
const el = event.currentTarget as HTMLElement | null;
|
||||||
|
const rect = el?.getBoundingClientRect();
|
||||||
|
if (!rect || rect.height <= 0) return index;
|
||||||
|
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function moveStageToIndex(
|
export function moveStageToIndex(
|
||||||
stages: PipelineStage[],
|
stages: PipelineStage[],
|
||||||
stageId: string,
|
stageId: string,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { RolePermissionKey, UserRole } from '@mento/shared';
|
import {
|
||||||
|
treasurySectionAllowed,
|
||||||
|
type RolePermissionKey,
|
||||||
|
type TreasurySectionKey,
|
||||||
|
type UserRole,
|
||||||
|
} from '@mento/shared';
|
||||||
|
|
||||||
/** مسیر صفحه ← مجوز بخش؛ همتراز SectionPermissionGuard در API */
|
/** مسیر صفحه ← مجوز بخش؛ همتراز SectionPermissionGuard در API */
|
||||||
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
|
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
|
||||||
@@ -39,6 +44,31 @@ export function hasSectionPermission(
|
|||||||
return Boolean(permissions?.includes(key));
|
return Boolean(permissions?.includes(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function financeSection(path: string): TreasurySectionKey | null {
|
||||||
|
const clean = path.split(/[?#]/)[0] ?? path;
|
||||||
|
if (!clean.startsWith('/finance')) return null;
|
||||||
|
if (clean.startsWith('/finance/payroll')) return 'treasury_payroll';
|
||||||
|
if (clean.startsWith('/finance/incomes')) return 'treasury_incomes';
|
||||||
|
if (clean.startsWith('/finance/expenses')) return 'treasury_expenses';
|
||||||
|
if (clean.startsWith('/finance/receivables')) return 'treasury_receivables';
|
||||||
|
if (clean.startsWith('/finance/accounts')) return 'treasury_accounts';
|
||||||
|
if (clean.startsWith('/finance/recurring')) return 'treasury_recurring';
|
||||||
|
if (clean.startsWith('/finance/reports')) return 'treasury_reports';
|
||||||
|
if (clean.startsWith('/finance/audit')) return 'treasury_audit';
|
||||||
|
if (clean.startsWith('/finance/categories')) return 'treasury_categories';
|
||||||
|
return 'treasury_dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenFinancePath(
|
||||||
|
role: UserRole | string | null | undefined,
|
||||||
|
permissions: readonly string[] | null | undefined,
|
||||||
|
path: string,
|
||||||
|
): boolean {
|
||||||
|
const section = financeSection(path);
|
||||||
|
if (!section) return true;
|
||||||
|
return treasurySectionAllowed(role, permissions, section);
|
||||||
|
}
|
||||||
|
|
||||||
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,7 @@ 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 { canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
|
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -330,7 +330,7 @@ export const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
roles: ['owner', 'admin'] as UserRole[],
|
roles: ['owner', 'admin'] as UserRole[],
|
||||||
treasuryTitle: 'حقوق و دستمزد',
|
treasuryTitle: 'حقوق و دستمزد',
|
||||||
treasuryDescription: 'فقط مالک و مدیر این صفحه را میبینند.',
|
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -678,10 +678,16 @@ router.beforeEach((to) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (String(to.path).startsWith('/finance')) {
|
if (String(to.path).startsWith('/finance')) {
|
||||||
const perms = auth.user?.permissions ?? [];
|
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||||
const payroll = to.path.startsWith('/finance/payroll');
|
return true;
|
||||||
if (payroll && perms.includes('treasury_payroll')) return true;
|
}
|
||||||
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
|
const exactHome = to.path === '/finance' || to.path === '/finance/';
|
||||||
|
if (
|
||||||
|
exactHome &&
|
||||||
|
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
|
||||||
|
) {
|
||||||
|
return { path: '/finance/payroll' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { name: 'home' };
|
return { name: 'home' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import { canAssignToOthers } from '@/lib/assign-access';
|
import { canAssignToOthers } from '@/lib/assign-access';
|
||||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
|
import {
|
||||||
|
leadNoteFileIsImage,
|
||||||
|
noteChips,
|
||||||
|
parseLeadFormNotes,
|
||||||
|
type LeadNoteFile,
|
||||||
|
type LeadNoteRow,
|
||||||
|
} from '@/lib/lead-form-notes';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -62,6 +69,51 @@ const moreOpen = ref(false);
|
|||||||
const commentOpen = ref(false);
|
const commentOpen = ref(false);
|
||||||
const postponeOpen = ref(false);
|
const postponeOpen = ref(false);
|
||||||
const noteOpen = ref(true);
|
const noteOpen = ref(true);
|
||||||
|
const noteModel = computed(() => parseLeadFormNotes(activity.value?.description));
|
||||||
|
const noteRows = computed(() =>
|
||||||
|
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
|
||||||
|
);
|
||||||
|
const noteExtra = computed(() =>
|
||||||
|
noteChips(
|
||||||
|
noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description,
|
||||||
|
).filter(
|
||||||
|
(chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخهایفرم:'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const recoveredImages = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
function noteImageSrc(file: LeadNoteFile): string | null {
|
||||||
|
if (!leadNoteFileIsImage(file)) return null;
|
||||||
|
return file.dataUrl || recoveredImages.value[file.name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => activity.value?.id, () => activity.value?.lead?.id, noteRows],
|
||||||
|
async ([activityId, leadId]) => {
|
||||||
|
const missing = noteRows.value
|
||||||
|
.map((row) => row.file)
|
||||||
|
.filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file)))
|
||||||
|
.map((file) => file.name);
|
||||||
|
recoveredImages.value = {};
|
||||||
|
if (!activityId || !leadId || !missing.length) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`);
|
||||||
|
if (activity.value?.id !== activityId) return;
|
||||||
|
const wanted = new Set(missing);
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const row of parseLeadFormNotes(data.notes).rows) {
|
||||||
|
const file = row.file;
|
||||||
|
if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) {
|
||||||
|
next[file.name] = file.dataUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recoveredImages.value = next;
|
||||||
|
} catch {
|
||||||
|
recoveredImages.value = {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const assignUserIds = ref<string[]>([]);
|
const assignUserIds = ref<string[]>([]);
|
||||||
|
|
||||||
@@ -565,8 +617,61 @@ onMounted(() => {
|
|||||||
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
||||||
</button>
|
</button>
|
||||||
<div v-show="noteOpen">
|
<div v-show="noteOpen">
|
||||||
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
|
<dl v-if="noteRows.length" class="note-rows">
|
||||||
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
|
||||||
|
<dt>{{ row.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
<template v-if="row.file && noteImageSrc(row.file)">
|
||||||
|
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
|
||||||
|
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
|
||||||
|
</a>
|
||||||
|
<span>{{ row.file.name }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="row.file">{{ row.file.name }}</span>
|
||||||
|
<template v-else>
|
||||||
|
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
|
||||||
|
<a
|
||||||
|
v-if="chip.kind === 'link'"
|
||||||
|
:href="chip.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ chip.label }}</a>
|
||||||
|
<a
|
||||||
|
v-else-if="chip.kind === 'image'"
|
||||||
|
class="note-shot"
|
||||||
|
:href="chip.src"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<img :src="chip.src" :alt="chip.name" />
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ chip.text }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
||||||
|
<p v-if="noteExtra.length" class="note-extra">
|
||||||
|
<template v-for="(chip, index) in noteExtra" :key="index">
|
||||||
|
<a
|
||||||
|
v-if="chip.kind === 'link'"
|
||||||
|
:href="chip.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ chip.label }}</a>
|
||||||
|
<a
|
||||||
|
v-else-if="chip.kind === 'image'"
|
||||||
|
class="note-shot"
|
||||||
|
:href="chip.src"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<img :src="chip.src" :alt="chip.name" />
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ chip.text }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
|
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
|
||||||
<a
|
<a
|
||||||
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
|
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
|
||||||
@@ -654,7 +759,13 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
padding-bottom: 5.5rem;
|
padding-bottom: 1rem;
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > * {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -876,6 +987,8 @@ dt {
|
|||||||
dd {
|
dd {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead-link {
|
.lead-link {
|
||||||
@@ -908,17 +1021,27 @@ dd {
|
|||||||
.assignees-row dd {
|
.assignees-row dd {
|
||||||
flex: 1 1 16rem;
|
flex: 1 1 16rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assignees-row dd :deep(.ant-select) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignees-row dd :deep(.ant-select-selection-overflow) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.flag.low,
|
.flag.low,
|
||||||
.priority.low {
|
.priority.low {
|
||||||
color: #64748b;
|
color: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag.medium,
|
.flag.medium,
|
||||||
.priority.medium {
|
.priority.medium {
|
||||||
color: #ea580c;
|
color: #eab308;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag.high,
|
.flag.high,
|
||||||
@@ -968,12 +1091,53 @@ dd {
|
|||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note p.empty {
|
.note p.empty {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-rows {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-rows > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr);
|
||||||
|
gap: 0.45rem 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-rows dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-rows dd {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-shot {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-shot img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(280px, 100%);
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.note-photos {
|
.note-photos {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1017,13 +1181,11 @@ dd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dock {
|
.dock {
|
||||||
position: fixed;
|
position: static;
|
||||||
inset-inline: 0.85rem;
|
|
||||||
bottom: calc(0.55rem + var(--mobile-tabbar-h));
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.15fr 1fr 1fr;
|
grid-template-columns: 1.15fr 1fr 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
z-index: 8;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.act {
|
.act {
|
||||||
@@ -1053,15 +1215,9 @@ dd {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (max-width: 899px) {
|
||||||
.page {
|
.page {
|
||||||
padding-bottom: 0;
|
padding-bottom: calc(1rem + var(--mobile-tabbar-h));
|
||||||
}
|
|
||||||
|
|
||||||
.dock {
|
|
||||||
position: sticky;
|
|
||||||
inset-inline: auto;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import { canManageSettings } from '@/lib/roles';
|
import { canManageSettings } from '@/lib/roles';
|
||||||
|
import { pointerDropIndex } from '@/lib/pipeline-board';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||||
|
|
||||||
@@ -481,28 +482,41 @@ function onDragOver(stageId: string, event: DragEvent) {
|
|||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDrop(stageId: string, event: DragEvent) {
|
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
const leadId =
|
const leadId =
|
||||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
dropStageId.value = null;
|
dropStageId.value = null;
|
||||||
if (!leadId) return;
|
if (!leadId || moving.value) return;
|
||||||
|
|
||||||
const fromCol = columns.value.find((c) =>
|
const fromCol = columns.value.find((c) =>
|
||||||
c.leads.some((l) => l.id === leadId),
|
c.leads.some((l) => l.id === leadId),
|
||||||
);
|
);
|
||||||
if (!fromCol || fromCol.stage.id === stageId) return;
|
const target = columns.value.find((c) => c.stage.id === stageId);
|
||||||
|
if (!fromCol || !target) return;
|
||||||
const lead = fromCol.leads.find((l) => l.id === leadId);
|
const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
|
||||||
|
const lead = fromCol.leads[fromIndex];
|
||||||
if (!lead) return;
|
if (!lead) return;
|
||||||
|
|
||||||
|
const same = fromCol.stage.id === stageId;
|
||||||
|
if (same && index == null) return;
|
||||||
|
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||||||
|
let insertAt = index == null || index < 0 ? 0 : index;
|
||||||
|
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||||
|
if (same && insertAt === fromIndex) return;
|
||||||
|
const nextLeads = [...without];
|
||||||
|
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||||
|
|
||||||
const prev = columns.value.map((c) => ({
|
const prev = columns.value.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
leads: [...c.leads],
|
leads: [...c.leads],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
columns.value = columns.value.map((c) => {
|
columns.value = columns.value.map((c) => {
|
||||||
|
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
|
||||||
if (c.stage.id === fromCol.stage.id) {
|
if (c.stage.id === fromCol.stage.id) {
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
@@ -511,11 +525,7 @@ async function onDrop(stageId: string, event: DragEvent) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (c.stage.id === stageId) {
|
if (c.stage.id === stageId) {
|
||||||
return {
|
return { ...c, total: c.total + 1, leads: nextLeads };
|
||||||
...c,
|
|
||||||
total: c.total + 1,
|
|
||||||
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
@@ -523,7 +533,7 @@ async function onDrop(stageId: string, event: DragEvent) {
|
|||||||
moving.value = true;
|
moving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await api.patch(`/leads/${leadId}/move`, { stageId });
|
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
|
||||||
} catch {
|
} catch {
|
||||||
columns.value = prev;
|
columns.value = prev;
|
||||||
error.value = 'جابهجایی لید ناموفق بود.';
|
error.value = 'جابهجایی لید ناموفق بود.';
|
||||||
@@ -644,9 +654,13 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :style="columnBodyStyle">
|
<div :style="columnBodyStyle">
|
||||||
<a-card
|
<div
|
||||||
v-for="lead in col.leads"
|
v-for="(lead, index) in col.leads"
|
||||||
:key="lead.id"
|
:key="lead.id"
|
||||||
|
@dragover.prevent="onDragOver(col.stage.id, $event)"
|
||||||
|
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||||
|
>
|
||||||
|
<a-card
|
||||||
hoverable
|
hoverable
|
||||||
:bordered="true"
|
:bordered="true"
|
||||||
:body-style="cardBodyStyle"
|
:body-style="cardBodyStyle"
|
||||||
@@ -786,6 +800,7 @@ onMounted(load);
|
|||||||
</a-flex>
|
</a-flex>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-typography-text
|
<a-typography-text
|
||||||
v-if="!col.leads.length"
|
v-if="!col.leads.length"
|
||||||
|
|||||||
@@ -312,11 +312,18 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
|||||||
const dealId = raw;
|
const dealId = raw;
|
||||||
if (!dealId || moving.value) return;
|
if (!dealId || moving.value) return;
|
||||||
|
|
||||||
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
|
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
|
||||||
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
|
if (current?.id === stageId && index == null) return;
|
||||||
if (current?.id === stageId && (index == null || index === currentIndex)) {
|
|
||||||
return;
|
const next = updateDealStage(stages.value, dealId, stageId, index);
|
||||||
}
|
const unchanged = next.every((stage, stageIndex) => {
|
||||||
|
const prev = stages.value[stageIndex];
|
||||||
|
return (
|
||||||
|
stage.deals.length === (prev?.deals.length ?? -1) &&
|
||||||
|
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (unchanged) return;
|
||||||
|
|
||||||
await applyUpdateDealStage(dealId, stageId, index);
|
await applyUpdateDealStage(dealId, stageId, index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DATA_ACCESS_SECTIONS,
|
DATA_ACCESS_SECTIONS,
|
||||||
ROLE_PERMISSION_ITEMS,
|
ROLE_PERMISSION_ITEMS,
|
||||||
type DataAccessPolicy,
|
type DataAccessPolicy,
|
||||||
|
type FinancialAccountDto,
|
||||||
type InviteUserRequest,
|
type InviteUserRequest,
|
||||||
type OrganizationDto,
|
type OrganizationDto,
|
||||||
type OrgModuleKey,
|
type OrgModuleKey,
|
||||||
@@ -159,7 +160,16 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
|||||||
conversations: ['conversations'],
|
conversations: ['conversations'],
|
||||||
conversations_groups: ['conversations'],
|
conversations_groups: ['conversations'],
|
||||||
treasury: ['treasury'],
|
treasury: ['treasury'],
|
||||||
|
treasury_dashboard: ['treasury'],
|
||||||
|
treasury_incomes: ['treasury'],
|
||||||
|
treasury_expenses: ['treasury'],
|
||||||
|
treasury_receivables: ['treasury'],
|
||||||
|
treasury_accounts: ['treasury'],
|
||||||
treasury_payroll: ['treasury'],
|
treasury_payroll: ['treasury'],
|
||||||
|
treasury_recurring: ['treasury'],
|
||||||
|
treasury_reports: ['treasury'],
|
||||||
|
treasury_audit: ['treasury'],
|
||||||
|
treasury_categories: ['treasury'],
|
||||||
behavior: ['behavior'],
|
behavior: ['behavior'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +233,10 @@ const roleFormDesc = ref('');
|
|||||||
const roleFormColor = ref<RoleColor>('teal');
|
const roleFormColor = ref<RoleColor>('teal');
|
||||||
const roleFormParentId = ref('');
|
const roleFormParentId = ref('');
|
||||||
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
||||||
|
const roleFormAccountIds = ref<string[]>([]);
|
||||||
|
const roleFormOwnAccounts = ref(false);
|
||||||
|
const roleFormAccounts = ref<Array<{ id: string; name: string; responsibleName: string }>>([]);
|
||||||
|
const roleAccountQuery = ref('');
|
||||||
|
|
||||||
const deleteOpen = ref(false);
|
const deleteOpen = ref(false);
|
||||||
const deleteSaving = ref(false);
|
const deleteSaving = ref(false);
|
||||||
@@ -693,7 +707,11 @@ function openCreateRole(parentId?: string | null) {
|
|||||||
? [...parent.permissions]
|
? [...parent.permissions]
|
||||||
: visibleRolePermissions.value.map((p) => p.key);
|
: visibleRolePermissions.value.map((p) => p.key);
|
||||||
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
|
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
|
||||||
|
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
|
||||||
|
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
|
||||||
|
roleAccountQuery.value = '';
|
||||||
roleFormOpen.value = true;
|
roleFormOpen.value = true;
|
||||||
|
void loadRoleAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateChildRole() {
|
function openCreateChildRole() {
|
||||||
@@ -713,7 +731,40 @@ function openEditRole() {
|
|||||||
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||||
roleFormPerms.value = [...role.permissions];
|
roleFormPerms.value = [...role.permissions];
|
||||||
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
|
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
|
||||||
|
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
|
||||||
|
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
|
||||||
|
roleAccountQuery.value = '';
|
||||||
roleFormOpen.value = true;
|
roleFormOpen.value = true;
|
||||||
|
void loadRoleAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTreasuryDetail(key: RolePermissionKey) {
|
||||||
|
return key.startsWith('treasury_');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRoleAccounts = computed(() => {
|
||||||
|
const query = roleAccountQuery.value.trim();
|
||||||
|
if (!query) return roleFormAccounts.value;
|
||||||
|
return roleFormAccounts.value.filter((account) => account.name.includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRoleAccounts() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||||
|
roleFormAccounts.value = data.map((account) => ({
|
||||||
|
id: account.id,
|
||||||
|
name: account.name,
|
||||||
|
responsibleName: account.responsibleName?.trim() ?? '',
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
roleFormAccounts.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRoleAccount(id: string) {
|
||||||
|
roleFormAccountIds.value = roleFormAccountIds.value.includes(id)
|
||||||
|
? roleFormAccountIds.value.filter((item) => item !== id)
|
||||||
|
: [...roleFormAccountIds.value, id];
|
||||||
}
|
}
|
||||||
|
|
||||||
function permOn(key: RolePermissionKey) {
|
function permOn(key: RolePermissionKey) {
|
||||||
@@ -721,6 +772,14 @@ function permOn(key: RolePermissionKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleFormPerm(key: RolePermissionKey) {
|
function toggleFormPerm(key: RolePermissionKey) {
|
||||||
|
if (key === 'treasury' && permOn('treasury')) {
|
||||||
|
roleFormPerms.value = roleFormPerms.value.filter(
|
||||||
|
(item) => item !== 'treasury' && !item.startsWith('treasury_'),
|
||||||
|
);
|
||||||
|
roleFormAccountIds.value = [];
|
||||||
|
roleFormOwnAccounts.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
roleFormPerms.value = permOn(key)
|
roleFormPerms.value = permOn(key)
|
||||||
? roleFormPerms.value.filter((p) => p !== key)
|
? roleFormPerms.value.filter((p) => p !== key)
|
||||||
: [...roleFormPerms.value, key];
|
: [...roleFormPerms.value, key];
|
||||||
@@ -744,12 +803,16 @@ async function submitRoleForm() {
|
|||||||
permissions: RolePermissionKey[];
|
permissions: RolePermissionKey[];
|
||||||
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
|
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
} = {
|
} = {
|
||||||
name: roleFormName.value.trim(),
|
name: roleFormName.value.trim(),
|
||||||
description: roleFormDesc.value.trim(),
|
description: roleFormDesc.value.trim(),
|
||||||
color: roleFormColor.value,
|
color: roleFormColor.value,
|
||||||
permissions: roleFormPerms.value,
|
permissions: roleFormPerms.value,
|
||||||
dataAccessPolicy: roleFormDataAccess.value,
|
dataAccessPolicy: roleFormDataAccess.value,
|
||||||
|
treasuryAccountIds: roleFormAccountIds.value,
|
||||||
|
treasuryOwnAccounts: roleFormOwnAccounts.value,
|
||||||
};
|
};
|
||||||
if (!editing?.systemKey) {
|
if (!editing?.systemKey) {
|
||||||
body.parentId = roleFormParentId.value || null;
|
body.parentId = roleFormParentId.value || null;
|
||||||
@@ -1661,14 +1724,53 @@ onUnmounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="مجوزهای دسترسی">
|
<a-form-item label="مجوزهای دسترسی">
|
||||||
<a-flex vertical :gap="4">
|
<a-flex vertical :gap="4">
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
تیک مدیریت مالی در را باز میکند. بخشهای ریز جدا انتخاب میشوند و با روشن کردن مدیریت مالی خودبهخود تیک نمیخورند.
|
||||||
|
تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخشها بهجز حقوق باز میمانند. با ذخیره حتی یک بخش، فقط همانها میمانند. حقوق جداست.
|
||||||
|
</a-typography-text>
|
||||||
|
<template v-for="perm in visibleRolePermissions" :key="perm.key">
|
||||||
|
<a-checkbox
|
||||||
|
v-if="!isTreasuryDetail(perm.key) || permOn('treasury')"
|
||||||
|
:checked="permOn(perm.key)"
|
||||||
|
:style="isTreasuryDetail(perm.key) ? { marginInlineStart: '24px' } : undefined"
|
||||||
|
@change="toggleFormPerm(perm.key)"
|
||||||
|
>
|
||||||
|
{{ perm.label }}
|
||||||
|
</a-checkbox>
|
||||||
|
</template>
|
||||||
|
</a-flex>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="permOn('treasury')" label="دسترسی حسابها">
|
||||||
|
<a-flex vertical :gap="4">
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
این بخش جدا از تیک بخشهای مالی است. میتوانید حسابهایی را بدهید که خود عضو مسئول آنهاست،
|
||||||
|
و جدا از آن حسابهای دیگر را هم انتخاب کنید. اگر هیچکدام انتخاب نشود، همه حسابها باز میماند.
|
||||||
|
</a-typography-text>
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
v-for="perm in visibleRolePermissions"
|
:checked="roleFormOwnAccounts"
|
||||||
:key="perm.key"
|
@change="roleFormOwnAccounts = !roleFormOwnAccounts"
|
||||||
:checked="permOn(perm.key)"
|
|
||||||
@change="toggleFormPerm(perm.key)"
|
|
||||||
>
|
>
|
||||||
{{ perm.label }}
|
حسابهایی که خودش مسئول آنهاست
|
||||||
</a-checkbox>
|
</a-checkbox>
|
||||||
|
<a-typography-text type="secondary">حسابهای دیگر</a-typography-text>
|
||||||
|
<a-input
|
||||||
|
v-model:value="roleAccountQuery"
|
||||||
|
placeholder="جستجوی حساب"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<a-checkbox
|
||||||
|
v-for="account in filteredRoleAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
:checked="roleFormAccountIds.includes(account.id)"
|
||||||
|
@change="toggleRoleAccount(account.id)"
|
||||||
|
>
|
||||||
|
{{ account.name }}
|
||||||
|
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||||
|
</a-checkbox>
|
||||||
|
<a-typography-text v-if="!roleFormAccounts.length" type="secondary">
|
||||||
|
حسابی برای انتخاب نیست.
|
||||||
|
</a-typography-text>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="محدوده داده">
|
<a-form-item label="محدوده داده">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@mento/shared';
|
||||||
import MoneyInput from '@/components/MoneyInput.vue';
|
import MoneyInput from '@/components/MoneyInput.vue';
|
||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import { toman } from './treasury-format';
|
import { toman } from './treasury-format';
|
||||||
@@ -35,12 +35,15 @@ const AListItemMeta = AList.Item.Meta;
|
|||||||
const ASelectOption = ASelect.Option;
|
const ASelectOption = ASelect.Option;
|
||||||
|
|
||||||
const accounts = ref<FinancialAccountDto[]>([]);
|
const accounts = ref<FinancialAccountDto[]>([]);
|
||||||
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const ok = ref('');
|
const ok = ref('');
|
||||||
const openForm = ref(false);
|
const openForm = ref(false);
|
||||||
|
const editingId = ref<string | null>(null);
|
||||||
|
const responsibleKey = ref<string | undefined>(undefined);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const transferOpen = ref(false);
|
const transferOpen = ref(false);
|
||||||
const form = ref({
|
const emptyForm = () => ({
|
||||||
name: '',
|
name: '',
|
||||||
type: 'cash' as FinancialAccountType,
|
type: 'cash' as FinancialAccountType,
|
||||||
bankName: '',
|
bankName: '',
|
||||||
@@ -50,6 +53,7 @@ const form = ref({
|
|||||||
openingDate: '',
|
openingDate: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const form = ref(emptyForm());
|
||||||
const transfer = ref({
|
const transfer = ref({
|
||||||
fromAccountId: '',
|
fromAccountId: '',
|
||||||
toAccountId: '',
|
toAccountId: '',
|
||||||
@@ -80,9 +84,54 @@ function apiMessage(e: unknown) {
|
|||||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responsibleOptions = computed(() => {
|
||||||
|
const rows = users.value.map((user) => ({ id: user.id, fullName: user.fullName }));
|
||||||
|
const current = form.value.responsibleName.trim();
|
||||||
|
if (current && !rows.some((row) => row.fullName === current)) {
|
||||||
|
rows.unshift({ id: `name:${current}`, fullName: current });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||||
accounts.value = data;
|
accounts.value = data;
|
||||||
|
try {
|
||||||
|
const usersRes = await api.get<OrgUserDto[]>('/users');
|
||||||
|
users.value = usersRes.data;
|
||||||
|
} catch {
|
||||||
|
users.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null;
|
||||||
|
responsibleKey.value = undefined;
|
||||||
|
form.value = emptyForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(item: FinancialAccountDto) {
|
||||||
|
editingId.value = item.id;
|
||||||
|
form.value = {
|
||||||
|
name: item.name,
|
||||||
|
type: item.type,
|
||||||
|
bankName: item.bankName ?? '',
|
||||||
|
accountRef: item.accountRef ?? '',
|
||||||
|
responsibleName: item.responsibleName ?? '',
|
||||||
|
openingBalance: item.openingBalance,
|
||||||
|
openingDate: item.openingDate.slice(0, 10),
|
||||||
|
notes: item.notes ?? '',
|
||||||
|
};
|
||||||
|
const match = users.value.find((user) => user.fullName === item.responsibleName);
|
||||||
|
responsibleKey.value = match?.id ?? (item.responsibleName ? `name:${item.responsibleName}` : undefined);
|
||||||
|
openForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function responsibleNameFromKey() {
|
||||||
|
const key = responsibleKey.value;
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.startsWith('name:')) return key.slice(5);
|
||||||
|
return users.value.find((user) => user.id === key)?.fullName ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFinishFailed() {
|
function onFinishFailed() {
|
||||||
@@ -95,23 +144,21 @@ async function createAccount() {
|
|||||||
ok.value = '';
|
ok.value = '';
|
||||||
try {
|
try {
|
||||||
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
|
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
|
||||||
await api.post('/treasury/accounts', {
|
const body = {
|
||||||
...form.value,
|
...form.value,
|
||||||
|
responsibleName: responsibleNameFromKey(),
|
||||||
openingBalance,
|
openingBalance,
|
||||||
openingDate: form.value.openingDate || undefined,
|
openingDate: form.value.openingDate || undefined,
|
||||||
});
|
|
||||||
openForm.value = false;
|
|
||||||
form.value = {
|
|
||||||
name: '',
|
|
||||||
type: 'cash',
|
|
||||||
bankName: '',
|
|
||||||
accountRef: '',
|
|
||||||
responsibleName: '',
|
|
||||||
openingBalance: '',
|
|
||||||
openingDate: '',
|
|
||||||
notes: '',
|
|
||||||
};
|
};
|
||||||
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
|
if (editingId.value) {
|
||||||
|
await api.patch(`/treasury/accounts/${editingId.value}`, body);
|
||||||
|
ok.value = 'حساب بهروز شد. موجودی از مانده افتتاحیه و گردش قطعی محاسبه میشود.';
|
||||||
|
} else {
|
||||||
|
await api.post('/treasury/accounts', body);
|
||||||
|
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
|
||||||
|
}
|
||||||
|
openForm.value = false;
|
||||||
|
resetForm();
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiMessage(e);
|
error.value = apiMessage(e);
|
||||||
@@ -193,12 +240,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
openForm = !openForm;
|
||||||
|
if (!openForm) resetForm();
|
||||||
|
"
|
||||||
|
>افزودن حساب جدید</a-button>
|
||||||
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
|
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|
||||||
<template v-if="openForm">
|
<template v-if="openForm">
|
||||||
<a-divider orientation="right">حساب جدید</a-divider>
|
<a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
|
||||||
<a-form
|
<a-form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
:model="form"
|
:model="form"
|
||||||
@@ -217,10 +270,15 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
|
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
|
||||||
<a-select v-model:value="form.type" style="width: 100%">
|
<a-select
|
||||||
<a-select-option value="cash">صندوق نقدی</a-select-option>
|
v-model:value="form.type"
|
||||||
<a-select-option value="bank">حساب بانکی</a-select-option>
|
show-search
|
||||||
<a-select-option value="petty">تنخواه</a-select-option>
|
option-filter-prop="label"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<a-select-option value="cash" label="صندوق نقدی">صندوق نقدی</a-select-option>
|
||||||
|
<a-select-option value="bank" label="حساب بانکی">حساب بانکی</a-select-option>
|
||||||
|
<a-select-option value="petty" label="تنخواه">تنخواه</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -235,8 +293,24 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="مسئول" name="responsibleName">
|
<a-form-item label="مسئول">
|
||||||
<a-input v-model:value="form.responsibleName" />
|
<a-select
|
||||||
|
v-model:value="responsibleKey"
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
option-filter-prop="label"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="انتخاب کاربر"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="user in responsibleOptions"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
:label="user.fullName"
|
||||||
|
>
|
||||||
|
{{ user.fullName }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
@@ -255,7 +329,9 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
|
<a-button type="primary" html-type="submit" :loading="saving">
|
||||||
|
{{ editingId ? 'ذخیره تغییرات' : 'ثبت حساب' }}
|
||||||
|
</a-button>
|
||||||
</a-form>
|
</a-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -276,11 +352,18 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="transfer.fromAccountId"
|
v-model:value="transfer.fromAccountId"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="انتخاب"
|
placeholder="انتخاب"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
|
<a-select-option
|
||||||
|
v-for="account in active"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -294,6 +377,8 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="transfer.toAccountId"
|
v-model:value="transfer.toAccountId"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="انتخاب"
|
placeholder="انتخاب"
|
||||||
@@ -302,6 +387,7 @@ onMounted(() => {
|
|||||||
v-for="account in active"
|
v-for="account in active"
|
||||||
:key="`to-${account.id}`"
|
:key="`to-${account.id}`"
|
||||||
:value="account.id"
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@@ -339,6 +425,7 @@ onMounted(() => {
|
|||||||
<a-list-item>
|
<a-list-item>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
|
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
|
||||||
|
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
|
||||||
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
|
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
@@ -347,7 +434,9 @@ onMounted(() => {
|
|||||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
|
{{ typeLabel[item.type] }}
|
||||||
|
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
|
||||||
|
· موجودی اولیه {{ toman(item.openingBalance) }}
|
||||||
</template>
|
</template>
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||||
<a-select v-model:value="form.kind" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||||
<a-select-option value="income">درآمد</a-select-option>
|
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||||
<a-select-option value="expense">هزینه</a-select-option>
|
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|||||||
@@ -185,13 +185,13 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="دستهبندی" name="categoryId">
|
<a-form-item label="دستهبندی" name="categoryId">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.categoryId"
|
v-model:value="form.categoryId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="بدون دسته"
|
placeholder="بدون دسته"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||||
{{ cat.name }}
|
{{ cat.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -204,13 +204,13 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="حساب" name="accountId">
|
<a-form-item label="حساب" name="accountId">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.accountId"
|
v-model:value="form.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="انتخاب حساب"
|
placeholder="انتخاب حساب"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -218,8 +218,8 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="روش" name="method">
|
<a-form-item label="روش" name="method">
|
||||||
<a-select v-model:value="form.method" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
|
||||||
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value" :label="opt.label">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
|
|||||||
@@ -372,11 +372,12 @@ onMounted(() => {
|
|||||||
<span>روش دریافت</span>
|
<span>روش دریافت</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<a-select v-model:value="form.method" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
|
||||||
<a-select-option
|
<a-select-option
|
||||||
v-for="opt in methodOptions"
|
v-for="opt in methodOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
>
|
>
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@@ -393,13 +394,13 @@ onMounted(() => {
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<a-flex :gap="8">
|
<a-flex :gap="8">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.categoryId"
|
v-model:value="form.categoryId"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="انتخاب دسته"
|
placeholder="انتخاب دسته"
|
||||||
style="flex: 1; min-width: 0"
|
style="flex: 1; min-width: 0"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||||
{{ cat.name }}
|
{{ cat.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -471,7 +472,7 @@ onMounted(() => {
|
|||||||
<span>حساب مقصد</span>
|
<span>حساب مقصد</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.accountId"
|
v-model:value="form.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="انتخاب حساب"
|
placeholder="انتخاب حساب"
|
||||||
@@ -481,6 +482,7 @@ onMounted(() => {
|
|||||||
v-for="account in accounts"
|
v-for="account in accounts"
|
||||||
:key="account.id"
|
:key="account.id"
|
||||||
:value="account.id"
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@@ -617,13 +619,13 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="دستهبندی" name="categoryId">
|
<a-form-item label="دستهبندی" name="categoryId">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.categoryId"
|
v-model:value="form.categoryId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="بدون دسته"
|
placeholder="بدون دسته"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||||
{{ cat.name }}
|
{{ cat.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -636,7 +638,7 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="حساب" name="accountId">
|
<a-form-item label="حساب" name="accountId">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.accountId"
|
v-model:value="form.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -646,6 +648,7 @@ onMounted(() => {
|
|||||||
v-for="account in accounts"
|
v-for="account in accounts"
|
||||||
:key="account.id"
|
:key="account.id"
|
||||||
:value="account.id"
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@@ -654,11 +657,12 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="روش" name="method">
|
<a-form-item label="روش" name="method">
|
||||||
<a-select v-model:value="form.method" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
|
||||||
<a-select-option
|
<a-select-option
|
||||||
v-for="opt in methodOptions"
|
v-for="opt in methodOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
>
|
>
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ onMounted(() => {
|
|||||||
name="userId"
|
name="userId"
|
||||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||||
>
|
>
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.userId"
|
v-model:value="form.userId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="انتخاب"
|
placeholder="انتخاب"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName">
|
||||||
{{ user.fullName }}
|
{{ user.fullName }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -239,7 +239,7 @@ onMounted(() => {
|
|||||||
name="accountId"
|
name="accountId"
|
||||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||||
>
|
>
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="pay.accountId"
|
v-model:value="pay.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -249,6 +249,7 @@ onMounted(() => {
|
|||||||
v-for="account in accounts"
|
v-for="account in accounts"
|
||||||
:key="account.id"
|
:key="account.id"
|
||||||
:value="account.id"
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
|
|||||||
@@ -244,13 +244,13 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="form.accountId"
|
v-model:value="form.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
placeholder="انتخاب"
|
placeholder="انتخاب"
|
||||||
>
|
>
|
||||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
@@ -263,9 +263,9 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="دوره" name="interval">
|
<a-form-item label="دوره" name="interval">
|
||||||
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
||||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
|
||||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -329,7 +329,7 @@ onMounted(() => {
|
|||||||
name="accountId"
|
name="accountId"
|
||||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||||
>
|
>
|
||||||
<a-select
|
<a-select show-search option-filter-prop="label"
|
||||||
v-model:value="collect.accountId"
|
v-model:value="collect.accountId"
|
||||||
allow-clear
|
allow-clear
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@@ -339,6 +339,7 @@ onMounted(() => {
|
|||||||
v-for="account in accounts"
|
v-for="account in accounts"
|
||||||
:key="account.id"
|
:key="account.id"
|
||||||
:value="account.id"
|
:value="account.id"
|
||||||
|
:label="account.name"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||||
<a-select v-model:value="form.kind" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||||
<a-select-option value="expense">هزینه</a-select-option>
|
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||||
<a-select-option value="income">درآمد</a-select-option>
|
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -128,10 +128,10 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
|
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
|
||||||
<a-select v-model:value="form.interval" style="width: 100%">
|
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%">
|
||||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
|
||||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
|
||||||
<a-select-option value="yearly">سالانه</a-select-option>
|
<a-select-option value="yearly" label="سالانه">سالانه</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|||||||
@@ -424,6 +424,11 @@ export interface OrgUserDto {
|
|||||||
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
|
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
|
||||||
*/
|
*/
|
||||||
dataAccessOverride?: DataAccessPolicy;
|
dataAccessOverride?: DataAccessPolicy;
|
||||||
|
/**
|
||||||
|
* null = دسترسی مالی از نقش میآید.
|
||||||
|
* آبجکت = جایگزین بخشها و حسابهای مالی همین کاربر.
|
||||||
|
*/
|
||||||
|
treasuryAccessOverride?: UserTreasuryAccess | null;
|
||||||
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
|
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
|
||||||
effectiveDataAccess?: DataAccessPolicy;
|
effectiveDataAccess?: DataAccessPolicy;
|
||||||
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
|
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
|
||||||
@@ -596,6 +601,39 @@ export interface UpdateUserDataAccessRequest {
|
|||||||
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک میشود).
|
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک میشود).
|
||||||
*/
|
*/
|
||||||
dataAccessOverride?: DataAccessPolicy | null;
|
dataAccessOverride?: DataAccessPolicy | null;
|
||||||
|
/**
|
||||||
|
* null = دسترسی مالی از نقش.
|
||||||
|
* نبودن فیلد یعنی این ذخیره به دسترسی مالی دست نمیزند.
|
||||||
|
*/
|
||||||
|
treasuryAccessOverride?: UserTreasuryAccess | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دسترسی مالی اختصاصی یک کاربر؛ جایگزین نقش میشود */
|
||||||
|
export type UserTreasuryAccess = {
|
||||||
|
enabled: boolean;
|
||||||
|
sections: RolePermissionKey[];
|
||||||
|
ownAccounts: boolean;
|
||||||
|
accountIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseUserTreasuryAccess(raw: unknown): UserTreasuryAccess | null {
|
||||||
|
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const sections = Array.isArray(obj.sections)
|
||||||
|
? obj.sections.filter(
|
||||||
|
(item): item is RolePermissionKey =>
|
||||||
|
typeof item === "string" && item.startsWith("treasury_"),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const accountIds = Array.isArray(obj.accountIds)
|
||||||
|
? obj.accountIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
enabled: obj.enabled === true,
|
||||||
|
sections: [...new Set(sections)],
|
||||||
|
ownAccounts: obj.ownAccounts === true,
|
||||||
|
accountIds: [...new Set(accountIds.map((id) => id.trim()))],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptySectionDataAccess(): SectionDataAccess {
|
export function emptySectionDataAccess(): SectionDataAccess {
|
||||||
@@ -741,7 +779,16 @@ export type RolePermissionKey =
|
|||||||
| "conversations"
|
| "conversations"
|
||||||
| "conversations_groups"
|
| "conversations_groups"
|
||||||
| "treasury"
|
| "treasury"
|
||||||
|
| "treasury_dashboard"
|
||||||
|
| "treasury_incomes"
|
||||||
|
| "treasury_expenses"
|
||||||
|
| "treasury_receivables"
|
||||||
|
| "treasury_accounts"
|
||||||
| "treasury_payroll"
|
| "treasury_payroll"
|
||||||
|
| "treasury_recurring"
|
||||||
|
| "treasury_reports"
|
||||||
|
| "treasury_audit"
|
||||||
|
| "treasury_categories"
|
||||||
| "behavior";
|
| "behavior";
|
||||||
|
|
||||||
/** بخشی که مسئولش را میشود به کاربر دیگری داد */
|
/** بخشی که مسئولش را میشود به کاربر دیگری داد */
|
||||||
@@ -787,10 +834,59 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
|||||||
{ key: "conversations", label: "گفتگو" },
|
{ key: "conversations", label: "گفتگو" },
|
||||||
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
|
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
|
||||||
{ key: "treasury", label: "مدیریت مالی" },
|
{ key: "treasury", label: "مدیریت مالی" },
|
||||||
{ key: "treasury_payroll", label: "حقوق و دستمزد" },
|
{ key: "treasury_dashboard", label: "مالی — داشبورد" },
|
||||||
|
{ key: "treasury_incomes", label: "مالی — دریافتها و درآمدها" },
|
||||||
|
{ key: "treasury_expenses", label: "مالی — پرداختها و هزینهها" },
|
||||||
|
{ key: "treasury_receivables", label: "مالی — اقساط و مطالبات" },
|
||||||
|
{ key: "treasury_accounts", label: "مالی — صندوق و حسابها" },
|
||||||
|
{ key: "treasury_payroll", label: "مالی — حقوق و دستمزد" },
|
||||||
|
{ key: "treasury_recurring", label: "مالی — تکرارشونده" },
|
||||||
|
{ key: "treasury_reports", label: "مالی — گزارش" },
|
||||||
|
{ key: "treasury_audit", label: "مالی — سوابق" },
|
||||||
|
{ key: "treasury_categories", label: "مالی — دستهبندیها" },
|
||||||
{ key: "behavior", label: "شخصیتشناسی و دستیار فروش" },
|
{ key: "behavior", label: "شخصیتشناسی و دستیار فروش" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** بخشهای ریز مدیریت مالی. حقوق جداست و با تیک خودش باز میشود. */
|
||||||
|
export const TREASURY_SECTION_KEYS = [
|
||||||
|
"treasury_dashboard",
|
||||||
|
"treasury_incomes",
|
||||||
|
"treasury_expenses",
|
||||||
|
"treasury_receivables",
|
||||||
|
"treasury_accounts",
|
||||||
|
"treasury_payroll",
|
||||||
|
"treasury_recurring",
|
||||||
|
"treasury_reports",
|
||||||
|
"treasury_audit",
|
||||||
|
"treasury_categories",
|
||||||
|
] as const satisfies readonly RolePermissionKey[];
|
||||||
|
|
||||||
|
export type TreasurySectionKey = (typeof TREASURY_SECTION_KEYS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دسترسی کلی «مدیریت مالی» در را باز میکند.
|
||||||
|
* اگر هیچ بخش ریزی انتخاب نشده باشد همه بخشها بهجز حقوق بازند.
|
||||||
|
* با انتخاب حتی یک بخش ریز، فقط همانها باز میمانند.
|
||||||
|
*/
|
||||||
|
export function treasurySectionAllowed(
|
||||||
|
role: string | null | undefined,
|
||||||
|
permissions: readonly string[] | null | undefined,
|
||||||
|
section: TreasurySectionKey | "treasury_documents",
|
||||||
|
): boolean {
|
||||||
|
if (role === "owner" || role === "admin") return true;
|
||||||
|
const perms = permissions ?? [];
|
||||||
|
if (section === "treasury_payroll") return perms.includes("treasury_payroll");
|
||||||
|
if (!perms.includes("treasury")) return false;
|
||||||
|
const fine = TREASURY_SECTION_KEYS.filter(
|
||||||
|
(key) => key !== "treasury_payroll" && perms.includes(key),
|
||||||
|
);
|
||||||
|
if (fine.length === 0) return true;
|
||||||
|
if (section === "treasury_documents") {
|
||||||
|
return perms.includes("treasury_incomes") || perms.includes("treasury_expenses");
|
||||||
|
}
|
||||||
|
return perms.includes(section);
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrgRoleMemberDto {
|
export interface OrgRoleMemberDto {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
@@ -816,6 +912,10 @@ export interface OrgRoleDto {
|
|||||||
childCount: number;
|
childCount: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
members: OrgRoleMemberDto[];
|
members: OrgRoleMemberDto[];
|
||||||
|
/** حسابهای اضافی؛ خالی بهتنهایی یعنی هنوز محدود نشده */
|
||||||
|
treasuryAccountIds: string[];
|
||||||
|
/** true یعنی اعضای نقش حسابهایی را میبینند که خودشان مسئول آنها هستند */
|
||||||
|
treasuryOwnAccounts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrgRoleRequest {
|
export interface CreateOrgRoleRequest {
|
||||||
@@ -826,6 +926,10 @@ export interface CreateOrgRoleRequest {
|
|||||||
dataAccessPolicy?: DataAccessPolicy;
|
dataAccessPolicy?: DataAccessPolicy;
|
||||||
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
|
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
/** حسابهای اضافی، جدا از حسابهایی که خودش مسئول است */
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
/** دسترسی به حسابهایی که نام خود عضو در مسئول ثبت شده */
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOrgRoleRequest {
|
export interface UpdateOrgRoleRequest {
|
||||||
@@ -836,6 +940,10 @@ export interface UpdateOrgRoleRequest {
|
|||||||
dataAccessPolicy?: DataAccessPolicy;
|
dataAccessPolicy?: DataAccessPolicy;
|
||||||
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
|
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
/** حسابهای اضافی، جدا از حسابهایی که خودش مسئول است */
|
||||||
|
treasuryAccountIds?: string[];
|
||||||
|
/** دسترسی به حسابهایی که نام خود عضو در مسئول ثبت شده */
|
||||||
|
treasuryOwnAccounts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteUserRequest {
|
export interface InviteUserRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user