From 1ccbefa7a3ac82e3d79bdfed0e5f2b27f141040d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 10:02:31 +0330 Subject: [PATCH] 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 --- .../migration.sql | 1 + .../migration.sql | 1 + .../migration.sql | 1 + apps/api/prisma/schema.prisma | 6 + apps/api/src/org-roles/dto.ts | 21 +++ apps/api/src/org-roles/org-roles.service.ts | 16 ++ .../api/src/treasury/treasury-access.guard.ts | 63 ++++++- apps/api/src/treasury/treasury.controller.ts | 13 +- apps/api/src/treasury/treasury.service.ts | 164 +++++++++++++++--- apps/api/src/users/dto.ts | 6 + apps/api/src/users/users.service.ts | 34 +++- .../src/components/DataAccessPolicyPanel.vue | 164 +++++++++++++++++- .../web/src/components/TeamsSettingsPanel.vue | 15 +- .../src/components/treasury/TreasuryNav.vue | 37 ++-- apps/web/src/layouts/AppLayout.vue | 9 +- apps/web/src/lib/section-permissions.ts | 32 +++- apps/web/src/router/index.ts | 18 +- apps/web/src/views/TeamSettingsView.vue | 106 ++++++++++- .../views/treasury/TreasuryAccountsView.vue | 141 ++++++++++++--- .../views/treasury/TreasuryCategoriesView.vue | 6 +- .../views/treasury/TreasuryExpensesView.vue | 12 +- .../views/treasury/TreasuryIncomesView.vue | 20 ++- .../views/treasury/TreasuryPayrollView.vue | 7 +- .../treasury/TreasuryReceivablesView.vue | 13 +- .../views/treasury/TreasuryRecurringView.vue | 14 +- packages/shared/src/index.ts | 110 +++++++++++- 26 files changed, 895 insertions(+), 135 deletions(-) create mode 100644 apps/api/prisma/migrations/20261104090000_role_treasury_accounts/migration.sql create mode 100644 apps/api/prisma/migrations/20261104110000_role_treasury_own_accounts/migration.sql create mode 100644 apps/api/prisma/migrations/20261104120000_user_treasury_access/migration.sql diff --git a/apps/api/prisma/migrations/20261104090000_role_treasury_accounts/migration.sql b/apps/api/prisma/migrations/20261104090000_role_treasury_accounts/migration.sql new file mode 100644 index 0000000..2a818c3 --- /dev/null +++ b/apps/api/prisma/migrations/20261104090000_role_treasury_accounts/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/migrations/20261104110000_role_treasury_own_accounts/migration.sql b/apps/api/prisma/migrations/20261104110000_role_treasury_own_accounts/migration.sql new file mode 100644 index 0000000..a97141e --- /dev/null +++ b/apps/api/prisma/migrations/20261104110000_role_treasury_own_accounts/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/migrations/20261104120000_user_treasury_access/migration.sql b/apps/api/prisma/migrations/20261104120000_user_treasury_access/migration.sql new file mode 100644 index 0000000..7371212 --- /dev/null +++ b/apps/api/prisma/migrations/20261104120000_user_treasury_access/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 030eef8..3d135b0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/org-roles/dto.ts b/apps/api/src/org-roles/dto.ts index f7a9292..3d3c335 100644 --- a/apps/api/src/org-roles/dto.ts +++ b/apps/api/src/org-roles/dto.ts @@ -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 { diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts index 03d53d7..4b52cd5 100644 --- a/apps/api/src/org-roles/org-roles.service.ts +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -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 { 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 { 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, diff --git a/apps/api/src/treasury/treasury-access.guard.ts b/apps/api/src/treasury/treasury-access.guard.ts index 63d3270..c08bc00 100644 --- a/apps/api/src/treasury/treasury-access.guard.ts +++ b/apps/api/src/treasury/treasury-access.guard.ts @@ -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, +): 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'; +} diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 903ab3f..5cfe7b3 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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); } diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index fe94167..c546a92 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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 { + 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 { - 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 { + 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 { 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 { + 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; } diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 19a2ddc..7a8923a 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto { @IsOptional() @IsObject() dataAccessOverride?: Record | null; + + /** null = ارث از نقش */ + @ValidateIf((_, v) => v !== null) + @IsOptional() + @IsObject() + treasuryAccessOverride?: Record | null; } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index c453f2a..ded01ea 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 | null }, + dto: { + dataAccessOverride?: Record | null; + treasuryAccessOverride?: Record | null; + }, ): Promise { 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, diff --git a/apps/web/src/components/DataAccessPolicyPanel.vue b/apps/web/src/components/DataAccessPolicyPanel.vue index 869171d..bd1c0ac 100644 --- a/apps/web/src/components/DataAccessPolicyPanel.vue +++ b/apps/web/src/components/DataAccessPolicyPanel.vue @@ -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('customers'); +const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_')); +const active = ref('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([]); const projectBoards = ref([]); const projectStages = ref([]); const routineCategories = ref([]); +const treasuryAccounts = ref([]); +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('/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(() => + 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] }} + + +
+

+ این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند. + در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست. + اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است. +

+ + +
{ :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)" /> @@ -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; +} diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index c6f35de..7bccf3a 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -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(null); const draftAccess = ref({}); +const draftTreasury = ref(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(() => { سفارشی برای این کاربر
diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index fd28d15..cf89e5a 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -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 }))); diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index ac54011..2cdebc1 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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) diff --git a/apps/web/src/lib/section-permissions.ts b/apps/web/src/lib/section-permissions.ts index 9ad054d..c81ebe8 100644 --- a/apps/web/src/lib/section-permissions.ts +++ b/apps/web/src/lib/section-permissions.ts @@ -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, diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 4145310..1530816 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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' }; } diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index c589585..ba9a5ea 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -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> = { 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('teal'); const roleFormParentId = ref(''); const roleFormPerms = ref(ROLE_PERMISSION_ITEMS.map((p) => p.key)); +const roleFormAccountIds = ref([]); +const roleFormOwnAccounts = ref(false); +const roleFormAccounts = ref>([]); +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('/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(() => {
مجوزهای دسترسی -
+
+ دسترسی حساب‌ها +

+ این بخش جدا از تیک بخش‌های مالی است. می‌توانید حساب‌هایی را بدهید که خود عضو مسئول آن‌هاست، + و جدا از آن حساب‌های دیگر را هم انتخاب کنید. اگر هیچ‌کدام انتخاب نشود، همه حساب‌ها باز می‌ماند. +

+ +

حساب‌های دیگر

+ + +

حسابی برای انتخاب نیست.

+
محدوده داده ([]); +const users = ref([]); const error = ref(''); const ok = ref(''); const openForm = ref(false); +const editingId = ref(null); +const responsibleKey = ref(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('/treasury/accounts'); accounts.value = data; + try { + const usersRes = await api.get('/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(() => { - افزودن حساب جدید + افزودن حساب جدید انتقال وجه @@ -276,11 +352,18 @@ onMounted(() => { > - + {{ account.name }} @@ -294,6 +377,8 @@ onMounted(() => { > { v-for="account in active" :key="`to-${account.id}`" :value="account.id" + :label="account.name" > {{ account.name }} @@ -339,6 +425,7 @@ onMounted(() => { @@ -347,7 +434,9 @@ onMounted(() => { غیرفعال diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue index 05b3b2b..20dadc1 100644 --- a/apps/web/src/views/treasury/TreasuryCategoriesView.vue +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -73,9 +73,9 @@ onMounted(() => { - - درآمد - هزینه + + درآمد + هزینه diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 338c75e..6f444b9 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -185,13 +185,13 @@ onMounted(() => { - - + {{ cat.name }} @@ -204,13 +204,13 @@ onMounted(() => { - - + {{ account.name }} @@ -218,8 +218,8 @@ onMounted(() => { - - + + {{ opt.label }} diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 06d3480..01e5606 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -372,11 +372,12 @@ onMounted(() => { روش دریافت - + {{ opt.label }} @@ -393,13 +394,13 @@ onMounted(() => { - - + {{ cat.name }} @@ -471,7 +472,7 @@ onMounted(() => { حساب مقصد - { v-for="account in accounts" :key="account.id" :value="account.id" + :label="account.name" > {{ account.name }} @@ -617,13 +619,13 @@ onMounted(() => { - - + {{ cat.name }} @@ -636,7 +638,7 @@ onMounted(() => { - { v-for="account in accounts" :key="account.id" :value="account.id" + :label="account.name" > {{ account.name }} @@ -654,11 +657,12 @@ onMounted(() => { - + {{ opt.label }} diff --git a/apps/web/src/views/treasury/TreasuryPayrollView.vue b/apps/web/src/views/treasury/TreasuryPayrollView.vue index 43f8d6d..01a4493 100644 --- a/apps/web/src/views/treasury/TreasuryPayrollView.vue +++ b/apps/web/src/views/treasury/TreasuryPayrollView.vue @@ -166,13 +166,13 @@ onMounted(() => { name="userId" :rules="[{ required: true, message: 'پرسنل الزامی است' }]" > - - + {{ user.fullName }} @@ -239,7 +239,7 @@ onMounted(() => { name="accountId" :rules="[{ required: true, message: 'حساب الزامی است' }]" > - { v-for="account in accounts" :key="account.id" :value="account.id" + :label="account.name" > {{ account.name }} diff --git a/apps/web/src/views/treasury/TreasuryReceivablesView.vue b/apps/web/src/views/treasury/TreasuryReceivablesView.vue index 7adc66a..77cbcee 100644 --- a/apps/web/src/views/treasury/TreasuryReceivablesView.vue +++ b/apps/web/src/views/treasury/TreasuryReceivablesView.vue @@ -244,13 +244,13 @@ onMounted(() => { - - + {{ account.name }} @@ -263,9 +263,9 @@ onMounted(() => { - - ماهانه - هفتگی + + ماهانه + هفتگی @@ -329,7 +329,7 @@ onMounted(() => { name="accountId" :rules="[{ required: true, message: 'حساب الزامی است' }]" > - { v-for="account in accounts" :key="account.id" :value="account.id" + :label="account.name" > {{ account.name }} diff --git a/apps/web/src/views/treasury/TreasuryRecurringView.vue b/apps/web/src/views/treasury/TreasuryRecurringView.vue index 5c62323..eedd9af 100644 --- a/apps/web/src/views/treasury/TreasuryRecurringView.vue +++ b/apps/web/src/views/treasury/TreasuryRecurringView.vue @@ -111,9 +111,9 @@ onMounted(() => { - - هزینه - درآمد + + هزینه + درآمد @@ -128,10 +128,10 @@ onMounted(() => { - - ماهانه - هفتگی - سالانه + + ماهانه + هفتگی + سالانه diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 40e20ba..d534472 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; + 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 {