fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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([])
|
||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||
dataAccessPolicy Json @default("{}")
|
||||
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
||||
treasuryAccountIds String[] @default([])
|
||||
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
||||
treasuryOwnAccounts Boolean @default(false)
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||
@@ -387,6 +391,8 @@ model Membership {
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||
dataAccessOverride Json @default("{}")
|
||||
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
||||
treasuryAccessOverride Json?
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateOrgRoleDto {
|
||||
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class RolePermissionPatchItemDto {
|
||||
|
||||
@@ -32,6 +32,8 @@ type RoleRow = {
|
||||
color: string;
|
||||
permissions: string[];
|
||||
dataAccessPolicy?: unknown;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
systemKey: string | null;
|
||||
parentId: string | null;
|
||||
createdAt: Date;
|
||||
@@ -79,6 +81,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -108,6 +112,8 @@ export class OrgRolesService {
|
||||
description: (dto.description ?? '').trim(),
|
||||
color: dto.color ?? parent?.color ?? 'teal',
|
||||
permissions,
|
||||
treasuryAccountIds: dto.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
|
||||
parentId,
|
||||
...(dto.dataAccessPolicy
|
||||
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
||||
@@ -135,6 +141,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -159,6 +167,12 @@ export class OrgRolesService {
|
||||
if (dto.dataAccessPolicy !== undefined) {
|
||||
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) {
|
||||
const nextParentId =
|
||||
@@ -426,6 +440,8 @@ export class OrgRolesService {
|
||||
color: row.color,
|
||||
permissions: sanitizePermissions(row.permissions),
|
||||
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
||||
treasuryAccountIds: row.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
|
||||
systemKey,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* حقوق فقط با treasury_payroll.
|
||||
* بقیه مسیرها با treasury باز میشوند؛ اگر بخش ریز انتخاب شده باشد فقط همانها.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TreasuryAccessGuard implements CanActivate {
|
||||
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 actor = req.user;
|
||||
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;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
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)
|
||||
: [];
|
||||
const allowed = payroll
|
||||
? permissions.includes('treasury_payroll')
|
||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
||||
const permissions = permissionsForTreasury(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
);
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||
}
|
||||
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(
|
||||
@CurrentUser() user: Actor,
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { parseUserTreasuryAccess } from '@mento/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
@@ -129,8 +131,12 @@ export class TreasuryService {
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const accounts = await this.prisma.financialAccount.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(scope ? { id: { in: scope } } : {}),
|
||||
},
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
||||
@@ -196,19 +202,89 @@ export class TreasuryService {
|
||||
async updateAccount(
|
||||
actor: Actor,
|
||||
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> {
|
||||
const existing = await this.requireAccount(actor.organizationId, id);
|
||||
const row = await this.prisma.financialAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||
...(input.responsibleName !== undefined
|
||||
? { responsibleName: input.responsibleName?.trim() || null }
|
||||
: {}),
|
||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||
},
|
||||
const existing = await this.requireAccount(actor.organizationId, id, actor);
|
||||
if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
|
||||
throw new BadRequestException('نوع حساب نامعتبر است.');
|
||||
}
|
||||
const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
|
||||
if (opening?.isNegative()) {
|
||||
throw new BadRequestException('موجودی اولیه نمیتواند منفی باشد.');
|
||||
}
|
||||
const openingDate =
|
||||
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);
|
||||
}
|
||||
@@ -218,7 +294,7 @@ export class TreasuryService {
|
||||
accountId: string,
|
||||
range?: { from?: string; to?: string },
|
||||
): 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 to = startOfDay(range?.to);
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
@@ -303,8 +379,8 @@ export class TreasuryService {
|
||||
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
||||
}
|
||||
const [source, target] = await Promise.all([
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId),
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId, actor),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId, actor),
|
||||
]);
|
||||
if (!source.isActive || !target.isActive) {
|
||||
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
||||
@@ -354,10 +430,12 @@ export class TreasuryService {
|
||||
kind: 'income' | 'expense',
|
||||
q?: string,
|
||||
): Promise<FinancialDocumentDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
@@ -408,7 +486,7 @@ export class TreasuryService {
|
||||
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
||||
}
|
||||
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 (input.kind === 'expense' && settle.greaterThan(0)) {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
@@ -482,7 +560,7 @@ export class TreasuryService {
|
||||
if (amount.greaterThan(remaining)) {
|
||||
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 (doc.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
@@ -644,9 +722,14 @@ export class TreasuryService {
|
||||
): Promise<TreasuryDashboardDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const scope = await this.accountScope(actor);
|
||||
const accountFilter = scope
|
||||
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
|
||||
: {};
|
||||
const bookedWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
status: { not: 'void' },
|
||||
...accountFilter,
|
||||
...(from || to
|
||||
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
@@ -654,6 +737,7 @@ export class TreasuryService {
|
||||
const cashWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
...(scope ? { accountId: { in: scope } } : {}),
|
||||
...(from || to
|
||||
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
@@ -680,12 +764,13 @@ export class TreasuryService {
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
status: { in: ['open', 'partial'] },
|
||||
...accountFilter,
|
||||
},
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.listAccounts(actor),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
where: { organizationId: actor.organizationId, ...accountFilter },
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
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({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
||||
if (actor) {
|
||||
const scope = await this.accountScope(actor);
|
||||
if (scope && !scope.includes(row.id)) {
|
||||
throw new ForbiddenException('به این حساب دسترسی ندارید.');
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
|
||||
/** null = ارث از نقش */
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
UpdateUserVisibilityRequest,
|
||||
UserRole,
|
||||
} 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 { parseVisibilityScopes } from '../common/record-scope';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -47,6 +52,7 @@ type MembershipRow = {
|
||||
dataVisibility?: string | null;
|
||||
dataVisibilityScopes?: unknown;
|
||||
dataAccessOverride?: unknown;
|
||||
treasuryAccessOverride?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
@@ -370,23 +376,35 @@ export class UsersService {
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
targetId: string,
|
||||
dto: { dataAccessOverride?: Record<string, unknown> | null },
|
||||
dto: {
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<OrgUserDto> {
|
||||
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
||||
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
|
||||
}
|
||||
if (dto.dataAccessOverride === undefined) {
|
||||
if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
|
||||
throw new BadRequestException('تغییری ارسال نشده است');
|
||||
}
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
if (target.role === 'owner' || target.role === 'admin') {
|
||||
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
|
||||
}
|
||||
const next =
|
||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||
const data: Prisma.MembershipUpdateInput = {};
|
||||
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({
|
||||
where: { id: target.id },
|
||||
data: { dataAccessOverride: next },
|
||||
data,
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
@@ -606,6 +624,10 @@ export class UsersService {
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? {}
|
||||
: parseDataAccessPolicy(row.dataAccessOverride),
|
||||
treasuryAccessOverride:
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? null
|
||||
: parseUserTreasuryAccess(row.treasuryAccessOverride),
|
||||
...(() => {
|
||||
const effectiveDataAccess = resolveAllSectionAccess({
|
||||
role: row.role,
|
||||
|
||||
@@ -3,13 +3,16 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessSection,
|
||||
FinancialAccountDto,
|
||||
PersonImportSourceStatDto,
|
||||
RolePermissionKey,
|
||||
SectionDataAccess,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
emptySectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -21,11 +24,16 @@ const props = defineProps<{
|
||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||
enabledPermissions?: RolePermissionKey[];
|
||||
disabled?: boolean;
|
||||
/** تبهای عمق داده قفل شوند؛ تب مدیریت مالی جدا میماند */
|
||||
dataLocked?: boolean;
|
||||
showTreasury?: boolean;
|
||||
treasuryAccess?: UserTreasuryAccess | null;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [DataAccessPolicy];
|
||||
'update:treasuryAccess': [UserTreasuryAccess | null];
|
||||
}>();
|
||||
|
||||
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
||||
@@ -47,12 +55,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(
|
||||
visibleSections,
|
||||
(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 },
|
||||
);
|
||||
@@ -89,6 +98,48 @@ const activityTypes = ref<IdOption[]>([]);
|
||||
const projectBoards = ref<IdOption[]>([]);
|
||||
const projectStages = 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 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() {
|
||||
await loadCustomerGroups().catch(() => undefined);
|
||||
@@ -176,8 +227,13 @@ async function loadOptions() {
|
||||
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
void loadTreasuryAccounts();
|
||||
});
|
||||
|
||||
const editorSection = computed<DataAccessSection>(() =>
|
||||
active.value === 'treasury' ? 'customers' : active.value,
|
||||
);
|
||||
|
||||
const stagesForActive = computed(() => {
|
||||
if (active.value === 'leads') return leadStages.value;
|
||||
if (active.value === 'sales') return dealStages.value;
|
||||
@@ -218,11 +274,94 @@ const typesForActive = computed(() => {
|
||||
>
|
||||
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTreasury"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: active === 'treasury' }"
|
||||
role="tab"
|
||||
:aria-selected="active === 'treasury'"
|
||||
@click="active = 'treasury'"
|
||||
>
|
||||
مدیریت مالی
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="active === 'treasury'" class="treasury">
|
||||
<p class="muted">
|
||||
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
||||
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
||||
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
||||
</p>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="user-treasury"
|
||||
:checked="treasuryAccess == null"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury(null)"
|
||||
/>
|
||||
مثل نقش
|
||||
</label>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="user-treasury"
|
||||
:checked="treasuryAccess != null"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
|
||||
/>
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<template v-if="treasuryAccess">
|
||||
<label class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.enabled"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
||||
/>
|
||||
دسترسی به مدیریت مالی
|
||||
</label>
|
||||
<template v-if="treasuryAccess.enabled">
|
||||
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.sections.includes(perm.key)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasurySection(perm.key)"
|
||||
/>
|
||||
{{ perm.label }}
|
||||
</label>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.ownAccounts"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
|
||||
/>
|
||||
حسابهایی که خودش مسئول آنهاست
|
||||
</label>
|
||||
<p class="muted">حسابهای دیگر</p>
|
||||
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
|
||||
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.accountIds.includes(account.id)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasuryAccount(account.id)"
|
||||
/>
|
||||
{{ account.name }}
|
||||
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||
</label>
|
||||
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<DataAccessEditor
|
||||
v-else
|
||||
:key="active"
|
||||
:section="active"
|
||||
:model-value="sectionValue(active)"
|
||||
:section="editorSection"
|
||||
:model-value="sectionValue(editorSection)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:pipelines="pipelines"
|
||||
@@ -231,9 +370,9 @@ const typesForActive = computed(() => {
|
||||
:import-sources="importSources"
|
||||
:import-sources-ready="importSourcesReady"
|
||||
:import-sources-failed="importSourcesFailed"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || dataLocked"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(active, $event)"
|
||||
@update:model-value="setSection(editorSection, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -268,4 +407,17 @@ const typesForActive = computed(() => {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.treasury {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.choice.sub {
|
||||
padding-inline-start: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TeamDto,
|
||||
TeamMemberRole,
|
||||
UpdatePriorArchiveCutoffRequest,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
@@ -279,8 +280,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 draftAccess = ref<DataAccessPolicy>({});
|
||||
const draftTreasury = ref<UserTreasuryAccess | null>(null);
|
||||
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
||||
const accessSaving = ref(false);
|
||||
|
||||
@@ -323,6 +329,7 @@ function openAccessEditor(u: OrgUserDto) {
|
||||
draftAccess.value = hasOverride
|
||||
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
||||
: clonePolicy(u.effectiveDataAccess);
|
||||
draftTreasury.value = u.treasuryAccessOverride ?? null;
|
||||
expandedAccess.value = u.id;
|
||||
}
|
||||
|
||||
@@ -333,8 +340,8 @@ async function saveUserAccess(userId: string) {
|
||||
try {
|
||||
const payload =
|
||||
accessMode.value === 'inherit'
|
||||
? { dataAccessOverride: null }
|
||||
: { dataAccessOverride: draftAccess.value };
|
||||
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
|
||||
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
|
||||
await api.patch(`/users/${userId}/data-access`, payload);
|
||||
ok.value = 'محدوده داده بهروز شد.';
|
||||
expandedAccess.value = null;
|
||||
@@ -528,8 +535,10 @@ onMounted(() => {
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<DataAccessPolicyPanel
|
||||
v-if="accessMode === 'custom'"
|
||||
v-model="draftAccess"
|
||||
v-model:treasury-access="draftTreasury"
|
||||
:show-treasury="showTreasury"
|
||||
:data-locked="accessMode === 'inherit'"
|
||||
compact
|
||||
/>
|
||||
<div class="actions">
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const canPayroll = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' ||
|
||||
auth.user?.role === 'admin' ||
|
||||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
);
|
||||
|
||||
const links = computed(() => [
|
||||
{ value: '/finance', label: 'داشبورد', exact: true },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ value: '/finance/reports', label: 'گزارش' },
|
||||
{ value: '/finance/audit', label: 'سوابق' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها' },
|
||||
]);
|
||||
const links = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
const permissions = auth.user?.permissions;
|
||||
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
|
||||
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها', section: 'treasury_incomes' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها', section: 'treasury_expenses' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها', section: 'treasury_accounts' },
|
||||
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها', section: 'treasury_categories' },
|
||||
];
|
||||
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||
});
|
||||
|
||||
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
|
||||
|
||||
|
||||
@@ -203,11 +203,14 @@ function cardAllowedForNav(card: {
|
||||
to?: string;
|
||||
}) {
|
||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||
const perms = auth.user?.permissions ?? [];
|
||||
const canTreasury = auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
||||
|| perms.includes('treasury')
|
||||
|| perms.some((key) => key.startsWith('treasury_'));
|
||||
if (card.to?.startsWith('/finance')) {
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { RolePermissionKey, UserRole } from '@mento/shared';
|
||||
import {
|
||||
treasurySectionAllowed,
|
||||
type RolePermissionKey,
|
||||
type TreasurySectionKey,
|
||||
type UserRole,
|
||||
} from '@mento/shared';
|
||||
|
||||
/** مسیر صفحه ← مجوز بخش؛ همتراز SectionPermissionGuard در API */
|
||||
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
|
||||
@@ -39,6 +44,31 @@ export function hasSectionPermission(
|
||||
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(
|
||||
role: UserRole | 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 { useAuthStore } from '@/stores/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({
|
||||
history: createWebHistory(),
|
||||
@@ -330,7 +330,7 @@ export const router = createRouter({
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'حقوق و دستمزد',
|
||||
treasuryDescription: 'فقط مالک و مدیر این صفحه را میبینند.',
|
||||
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -678,10 +678,16 @@ router.beforeEach((to) => {
|
||||
return true;
|
||||
}
|
||||
if (String(to.path).startsWith('/finance')) {
|
||||
const perms = auth.user?.permissions ?? [];
|
||||
const payroll = to.path.startsWith('/finance/payroll');
|
||||
if (payroll && perms.includes('treasury_payroll')) return true;
|
||||
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
|
||||
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
|
||||
return true;
|
||||
}
|
||||
const exactHome = to.path === '/finance' || to.path === '/finance/';
|
||||
if (
|
||||
exactHome &&
|
||||
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
|
||||
) {
|
||||
return { path: '/finance/payroll' };
|
||||
}
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DATA_ACCESS_SECTIONS,
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
type DataAccessPolicy,
|
||||
type FinancialAccountDto,
|
||||
type InviteUserRequest,
|
||||
type OrganizationDto,
|
||||
type OrgModuleKey,
|
||||
@@ -74,7 +75,16 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
||||
conversations: ['conversations'],
|
||||
conversations_groups: ['conversations'],
|
||||
treasury: ['treasury'],
|
||||
treasury_dashboard: ['treasury'],
|
||||
treasury_incomes: ['treasury'],
|
||||
treasury_expenses: ['treasury'],
|
||||
treasury_receivables: ['treasury'],
|
||||
treasury_accounts: ['treasury'],
|
||||
treasury_payroll: ['treasury'],
|
||||
treasury_recurring: ['treasury'],
|
||||
treasury_reports: ['treasury'],
|
||||
treasury_audit: ['treasury'],
|
||||
treasury_categories: ['treasury'],
|
||||
behavior: ['behavior'],
|
||||
};
|
||||
|
||||
@@ -139,6 +149,10 @@ const roleFormDesc = ref('');
|
||||
const roleFormColor = ref<RoleColor>('teal');
|
||||
const roleFormParentId = ref('');
|
||||
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 deleteSaving = ref(false);
|
||||
@@ -616,7 +630,11 @@ function openCreateRole(parentId?: string | null) {
|
||||
? [...parent.permissions]
|
||||
: visibleRolePermissions.value.map((p) => p.key);
|
||||
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
|
||||
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
|
||||
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
|
||||
roleAccountQuery.value = '';
|
||||
roleFormOpen.value = true;
|
||||
void loadRoleAccounts();
|
||||
}
|
||||
|
||||
function openCreateChildRole() {
|
||||
@@ -636,7 +654,40 @@ function openEditRole() {
|
||||
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||
roleFormPerms.value = [...role.permissions];
|
||||
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
|
||||
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
|
||||
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
|
||||
roleAccountQuery.value = '';
|
||||
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) {
|
||||
@@ -644,6 +695,14 @@ function permOn(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.filter((p) => p !== key)
|
||||
: [...roleFormPerms.value, key];
|
||||
@@ -667,12 +726,16 @@ async function submitRoleForm() {
|
||||
permissions: RolePermissionKey[];
|
||||
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
} = {
|
||||
name: roleFormName.value.trim(),
|
||||
description: roleFormDesc.value.trim(),
|
||||
color: roleFormColor.value,
|
||||
permissions: roleFormPerms.value,
|
||||
dataAccessPolicy: roleFormDataAccess.value,
|
||||
treasuryAccountIds: roleFormAccountIds.value,
|
||||
treasuryOwnAccounts: roleFormOwnAccounts.value,
|
||||
};
|
||||
if (!editing?.systemKey) {
|
||||
body.parentId = roleFormParentId.value || null;
|
||||
@@ -1491,11 +1554,48 @@ onUnmounted(() => {
|
||||
</label>
|
||||
<fieldset class="perm-set">
|
||||
<legend>مجوزهای دسترسی</legend>
|
||||
<label v-for="perm in visibleRolePermissions" :key="perm.key" class="perm-row">
|
||||
<p class="hint">
|
||||
تیک مدیریت مالی در را باز میکند. بخشهای ریز جدا انتخاب میشوند و با روشن کردن مدیریت مالی خودبهخود تیک نمیخورند.
|
||||
تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخشها بهجز حقوق باز میمانند. با ذخیره حتی یک بخش، فقط همانها میمانند. حقوق جداست.
|
||||
</p>
|
||||
<label
|
||||
v-for="perm in visibleRolePermissions"
|
||||
v-show="!isTreasuryDetail(perm.key) || permOn('treasury')"
|
||||
:key="perm.key"
|
||||
class="perm-row"
|
||||
:class="{ 'perm-sub': isTreasuryDetail(perm.key) }"
|
||||
>
|
||||
<input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
|
||||
{{ perm.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset v-if="permOn('treasury')" class="perm-set">
|
||||
<legend>دسترسی حسابها</legend>
|
||||
<p class="hint">
|
||||
این بخش جدا از تیک بخشهای مالی است. میتوانید حسابهایی را بدهید که خود عضو مسئول آنهاست،
|
||||
و جدا از آن حسابهای دیگر را هم انتخاب کنید. اگر هیچکدام انتخاب نشود، همه حسابها باز میماند.
|
||||
</p>
|
||||
<label class="perm-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="roleFormOwnAccounts"
|
||||
@change="roleFormOwnAccounts = !roleFormOwnAccounts"
|
||||
/>
|
||||
حسابهایی که خودش مسئول آنهاست
|
||||
</label>
|
||||
<p class="hint">حسابهای دیگر</p>
|
||||
<input v-model="roleAccountQuery" placeholder="جستجوی حساب" />
|
||||
<label v-for="account in filteredRoleAccounts" :key="account.id" class="perm-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="roleFormAccountIds.includes(account.id)"
|
||||
@change="toggleRoleAccount(account.id)"
|
||||
/>
|
||||
{{ account.name }}
|
||||
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||
</label>
|
||||
<p v-if="!roleFormAccounts.length" class="hint">حسابی برای انتخاب نیست.</p>
|
||||
</fieldset>
|
||||
<fieldset class="perm-set">
|
||||
<legend>محدوده داده</legend>
|
||||
<DataAccessPolicyPanel
|
||||
@@ -2381,6 +2481,10 @@ textarea {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.perm-sub {
|
||||
padding-inline-start: 1.4rem;
|
||||
}
|
||||
|
||||
.sheet-handle,
|
||||
.sheet-backdrop {
|
||||
display: none;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
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 JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
@@ -35,12 +35,15 @@ const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const openForm = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const responsibleKey = ref<string | undefined>(undefined);
|
||||
const saving = ref(false);
|
||||
const transferOpen = ref(false);
|
||||
const form = ref({
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
type: 'cash' as FinancialAccountType,
|
||||
bankName: '',
|
||||
@@ -50,6 +53,7 @@ const form = ref({
|
||||
openingDate: '',
|
||||
notes: '',
|
||||
});
|
||||
const form = ref(emptyForm());
|
||||
const transfer = ref({
|
||||
fromAccountId: '',
|
||||
toAccountId: '',
|
||||
@@ -80,9 +84,54 @@ function apiMessage(e: unknown) {
|
||||
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() {
|
||||
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||
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() {
|
||||
@@ -95,23 +144,21 @@ async function createAccount() {
|
||||
ok.value = '';
|
||||
try {
|
||||
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/accounts', {
|
||||
const body = {
|
||||
...form.value,
|
||||
responsibleName: responsibleNameFromKey(),
|
||||
openingBalance,
|
||||
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();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -193,12 +240,18 @@ onMounted(() => {
|
||||
|
||||
<a-card :bordered="false">
|
||||
<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-space>
|
||||
|
||||
<template v-if="openForm">
|
||||
<a-divider orientation="right">حساب جدید</a-divider>
|
||||
<a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@@ -217,10 +270,15 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.type" style="width: 100%">
|
||||
<a-select-option value="cash">صندوق نقدی</a-select-option>
|
||||
<a-select-option value="bank">حساب بانکی</a-select-option>
|
||||
<a-select-option value="petty">تنخواه</a-select-option>
|
||||
<a-select
|
||||
v-model:value="form.type"
|
||||
show-search
|
||||
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-form-item>
|
||||
</a-col>
|
||||
@@ -235,8 +293,24 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مسئول" name="responsibleName">
|
||||
<a-input v-model:value="form.responsibleName" />
|
||||
<a-form-item label="مسئول">
|
||||
<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-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -255,7 +329,9 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -276,11 +352,18 @@ onMounted(() => {
|
||||
>
|
||||
<a-select
|
||||
v-model:value="transfer.fromAccountId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -294,6 +377,8 @@ onMounted(() => {
|
||||
>
|
||||
<a-select
|
||||
v-model:value="transfer.toAccountId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
@@ -302,6 +387,7 @@ onMounted(() => {
|
||||
v-for="account in active"
|
||||
:key="`to-${account.id}`"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -339,6 +425,7 @@ onMounted(() => {
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
@@ -347,7 +434,9 @@ onMounted(() => {
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
|
||||
{{ typeLabel[item.type] }}
|
||||
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
|
||||
· موجودی اولیه {{ toman(item.openingBalance) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
|
||||
@@ -73,9 +73,9 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
@@ -185,13 +185,13 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -204,13 +204,13 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -218,8 +218,8 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
||||
<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" :label="opt.label">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
|
||||
@@ -372,11 +372,12 @@ onMounted(() => {
|
||||
<span>روش دریافت</span>
|
||||
</a-space>
|
||||
</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
|
||||
v-for="opt in methodOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
@@ -393,13 +394,13 @@ onMounted(() => {
|
||||
</a-space>
|
||||
</template>
|
||||
<a-flex :gap="8">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -471,7 +472,7 @@ onMounted(() => {
|
||||
<span>حساب مقصد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
placeholder="انتخاب حساب"
|
||||
@@ -481,6 +482,7 @@ onMounted(() => {
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -617,13 +619,13 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -636,7 +638,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
@@ -646,6 +648,7 @@ onMounted(() => {
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -654,11 +657,12 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<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"
|
||||
:label="opt.label"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
|
||||
@@ -166,13 +166,13 @@ onMounted(() => {
|
||||
name="userId"
|
||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.userId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -239,7 +239,7 @@ onMounted(() => {
|
||||
name="accountId"
|
||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="pay.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
@@ -249,6 +249,7 @@ onMounted(() => {
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
|
||||
@@ -244,13 +244,13 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
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 }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -263,9 +263,9 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
||||
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -329,7 +329,7 @@ onMounted(() => {
|
||||
name="accountId"
|
||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="collect.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
@@ -339,6 +339,7 @@ onMounted(() => {
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
|
||||
@@ -111,9 +111,9 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -128,10 +128,10 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.interval" style="width: 100%">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
<a-select-option value="yearly">سالانه</a-select-option>
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%">
|
||||
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
|
||||
<a-select-option value="yearly" label="سالانه">سالانه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
@@ -424,6 +424,11 @@ export interface OrgUserDto {
|
||||
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
|
||||
*/
|
||||
dataAccessOverride?: DataAccessPolicy;
|
||||
/**
|
||||
* null = دسترسی مالی از نقش میآید.
|
||||
* آبجکت = جایگزین بخشها و حسابهای مالی همین کاربر.
|
||||
*/
|
||||
treasuryAccessOverride?: UserTreasuryAccess | null;
|
||||
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
|
||||
effectiveDataAccess?: DataAccessPolicy;
|
||||
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
|
||||
@@ -596,6 +601,39 @@ export interface UpdateUserDataAccessRequest {
|
||||
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک میشود).
|
||||
*/
|
||||
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 {
|
||||
@@ -741,7 +779,16 @@ export type RolePermissionKey =
|
||||
| "conversations"
|
||||
| "conversations_groups"
|
||||
| "treasury"
|
||||
| "treasury_dashboard"
|
||||
| "treasury_incomes"
|
||||
| "treasury_expenses"
|
||||
| "treasury_receivables"
|
||||
| "treasury_accounts"
|
||||
| "treasury_payroll"
|
||||
| "treasury_recurring"
|
||||
| "treasury_reports"
|
||||
| "treasury_audit"
|
||||
| "treasury_categories"
|
||||
| "behavior";
|
||||
|
||||
/** بخشی که مسئولش را میشود به کاربر دیگری داد */
|
||||
@@ -787,10 +834,59 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
{ key: "conversations", label: "گفتگو" },
|
||||
{ key: "conversations_groups", 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: "شخصیتشناسی و دستیار فروش" },
|
||||
];
|
||||
|
||||
/** بخشهای ریز مدیریت مالی. حقوق جداست و با تیک خودش باز میشود. */
|
||||
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 {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -816,6 +912,10 @@ export interface OrgRoleDto {
|
||||
childCount: number;
|
||||
userCount: number;
|
||||
members: OrgRoleMemberDto[];
|
||||
/** حسابهای اضافی؛ خالی بهتنهایی یعنی هنوز محدود نشده */
|
||||
treasuryAccountIds: string[];
|
||||
/** true یعنی اعضای نقش حسابهایی را میبینند که خودشان مسئول آنها هستند */
|
||||
treasuryOwnAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface CreateOrgRoleRequest {
|
||||
@@ -826,6 +926,10 @@ export interface CreateOrgRoleRequest {
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
|
||||
parentId?: string | null;
|
||||
/** حسابهای اضافی، جدا از حسابهایی که خودش مسئول است */
|
||||
treasuryAccountIds?: string[];
|
||||
/** دسترسی به حسابهایی که نام خود عضو در مسئول ثبت شده */
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateOrgRoleRequest {
|
||||
@@ -836,6 +940,10 @@ export interface UpdateOrgRoleRequest {
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
|
||||
parentId?: string | null;
|
||||
/** حسابهای اضافی، جدا از حسابهایی که خودش مسئول است */
|
||||
treasuryAccountIds?: string[];
|
||||
/** دسترسی به حسابهایی که نام خود عضو در مسئول ثبت شده */
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteUserRequest {
|
||||
|
||||
Reference in New Issue
Block a user