diff --git a/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql b/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql new file mode 100644 index 0000000..5a30093 --- /dev/null +++ b/apps/api/prisma/migrations/20261103210000_activity_board_position/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition"); 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 af53b34..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 @@ -896,6 +902,8 @@ model Activity { /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع stageId String? stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) + /// ترتیب کارت داخل همان ستون کانبان + boardPosition Int @default(0) /// نوع سفارشی؛ null → نوع اصلیِ همان type typeDefId String? typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) @@ -911,6 +919,7 @@ model Activity { updatedAt DateTime @updatedAt @@index([stageId]) + @@index([stageId, boardPosition]) @@index([boardId]) @@index([typeDefId]) @@index([leadId]) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index ba6de59..af1e778 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -428,7 +428,13 @@ export class ActivitiesController { @Param('id') id: string, @Body() dto: MoveActivityStageDto, ) { - return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId); + return this.activitiesService.moveToStage( + user.organizationId, + id, + user, + dto.stageId, + dto.position, + ); } @Patch(':id/complete') diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 31c8850..537d2ef 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -31,6 +31,7 @@ import { ownerWhere, } from '../common/record-scope'; import { assertAssigneeSet } from '../common/assign-access'; +import { placeId, sameIds, writePositions } from '../common/column-order'; import { normalizeTags } from '../common/tags'; import { GamificationService } from '../gamification/gamification.service'; import { tehranDayKey } from '../routines/routine-dates'; @@ -329,9 +330,7 @@ export class ActivitiesService { where, include, // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد - orderBy: entity?.leadId - ? [{ createdAt: 'desc' }] - : [{ dueAt: 'asc' }, { createdAt: 'desc' }], + orderBy: this.listOrder(entity, stageScope), skip: (page - 1) * take, take, }), @@ -385,7 +384,7 @@ export class ActivitiesService { this.prisma.activity.findMany({ where, select: { id: true }, - orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], + orderBy: this.listOrder(entity, stageScope), take: cap, }), this.prisma.activity.count({ where }), @@ -490,11 +489,46 @@ export class ActivitiesService { return { counts, total }; } + private listOrder( + entity?: { leadId?: string }, + stageScope?: StageScope, + ): Prisma.ActivityOrderByWithRelationInput[] { + if (entity?.leadId) return [{ createdAt: 'desc' }]; + if (stageScope && stageScope.stageId !== 'all') { + return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }]; + } + return [{ dueAt: 'asc' }, { createdAt: 'desc' }]; + } + + private async reorderStage( + organizationId: string, + stageId: string, + activityId: string, + index: number, + ) { + const where = await this.stages.whereForStage(organizationId, stageId); + const rows = await this.prisma.activity.findMany({ + where, + select: { id: true }, + orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }], + }); + const currentIds = rows.map((row) => row.id); + const placed = placeId(currentIds, activityId, index); + if (sameIds(currentIds, placed)) return; + await writePositions( + (ops) => this.prisma.$transaction(ops), + placed, + (id, boardPosition) => + this.prisma.activity.update({ where: { id }, data: { boardPosition } }), + ); + } + async moveToStage( organizationId: string, id: string, actor: ActivityActor, stageId: string, + position?: number, ): Promise { await this.ensureExists(organizationId, id, actor); const stage = await this.stages.get(organizationId, stageId); @@ -519,6 +553,7 @@ export class ActivitiesService { }, include, }); + if (position != null) await this.reorderStage(organizationId, stage.id, id, position); if (stage.kind === 'done' && existing?.status !== 'done') { await this.fireDone(organizationId, row); } diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index e39b27d..cab5131 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -219,6 +219,12 @@ export class ReorderActivityStagesDto { export class MoveActivityStageDto { @IsString() stageId!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + position?: number; } /** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */ @@ -316,7 +322,7 @@ export class CreateActivityDto { @IsOptional() @IsString() - @MaxLength(2000) + @MaxLength(20000) description?: string; @IsOptional() @@ -416,7 +422,7 @@ export class UpdateActivityDto { @IsOptional() @IsString() - @MaxLength(2000) + @MaxLength(20000) description?: string | null; @IsOptional() diff --git a/apps/api/src/common/column-order.ts b/apps/api/src/common/column-order.ts new file mode 100644 index 0000000..560412d --- /dev/null +++ b/apps/api/src/common/column-order.ts @@ -0,0 +1,28 @@ +import type { Prisma } from '@prisma/client'; + +type PositionUpdate = Prisma.PrismaPromise; + +/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */ +export function placeId(ids: string[], movingId: string, index: number): string[] { + const next = ids.filter((id) => id !== movingId); + const at = Math.max(0, Math.min(Math.trunc(index), next.length)); + next.splice(at, 0, movingId); + return next; +} + +export function sameIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} + +/** ترتیب ستون را ۰ تا n ذخیره می‌کند. */ +export async function writePositions( + transaction: (ops: PositionUpdate[]) => Promise, + ids: string[], + update: (id: string, position: number) => PositionUpdate, +) { + const size = 60; + for (let start = 0; start < ids.length; start += size) { + const slice = ids.slice(start, start + size); + await transaction(slice.map((id, offset) => update(id, start + offset))); + } +} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index fff017e..d266139 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -32,6 +32,7 @@ import { forScope, } from '../common/record-scope'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; +import { placeId, sameIds, writePositions } from '../common/column-order'; import { asAddresses, collectAddresses, @@ -613,24 +614,43 @@ export class LeadsService { }); if (!stage) throw new NotFoundException('مرحله پیدا نشد'); + let placedIds: string[] | null = null; + let nextPosition = position ?? 0; + if (position != null) { + const rows = await this.prisma.lead.findMany({ + where: { organizationId: actor.organizationId, stageId, archivedAt: null }, + select: { id: true }, + orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], + }); + const currentIds = rows.map((row) => row.id); + placedIds = placeId(currentIds, id, position); + nextPosition = Math.max(0, placedIds.indexOf(id)); + if (sameIds(currentIds, placedIds)) placedIds = null; + } + const row = await this.prisma.lead.update({ where: { id }, data: { stageId, - position: position ?? 0, - ...(stage.isConverted - ? {} - : {}), + position: nextPosition, }, include: leadInclude, }); - await this.addEvent( - actor, - id, - 'stage_changed', - `به مرحله «${stage.name}» منتقل شد`, - ); + if (placedIds) { + await writePositions( + (ops) => this.prisma.$transaction(ops), + placedIds, + (leadId, leadPosition) => + this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }), + ); + } if (before.stageId !== stageId) { + await this.addEvent( + actor, + id, + 'stage_changed', + `به مرحله «${stage.name}» منتقل شد`, + ); this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { eventKey: `${id}:${stageId}`, facts: { stageName: stage.name, source: row.source }, 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/pipelines/deals.service.ts b/apps/api/src/pipelines/deals.service.ts index 7528e57..68df5ac 100644 --- a/apps/api/src/pipelines/deals.service.ts +++ b/apps/api/src/pipelines/deals.service.ts @@ -27,6 +27,7 @@ import { resolveOwnerId, } from '../common/record-scope'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; +import { placeId, sameIds, writePositions } from '../common/column-order'; const dealInclude = { person: { select: { id: true, firstName: true, lastName: true } }, @@ -379,7 +380,8 @@ export class DealsService { } const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open'; - const position = + let placedIds: string[] | null = null; + let position = dto.position ?? (( await this.prisma.deal.aggregate({ @@ -387,6 +389,17 @@ export class DealsService { _max: { position: true }, }) )._max.position ?? -1) + 1; + if (dto.position != null) { + const rows = await this.prisma.deal.findMany({ + where: { organizationId, stageId: stage.id }, + select: { id: true }, + orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }], + }); + const currentIds = rows.map((row) => row.id); + placedIds = placeId(currentIds, deal.id, dto.position); + position = Math.max(0, placedIds.indexOf(deal.id)); + if (sameIds(currentIds, placedIds)) placedIds = null; + } const row = await this.prisma.deal.update({ where: { id }, @@ -402,6 +415,15 @@ export class DealsService { include: dealInclude, }); + if (placedIds) { + await writePositions( + (ops) => this.prisma.$transaction(ops), + placedIds, + (dealId, nextPosition) => + this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }), + ); + } + if (deal.ownerId && deal.stageId !== stage.id) { await this.notifications.create({ organizationId, 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 5fc866c..472a81f 100644 --- a/apps/web/src/components/DataAccessPolicyPanel.vue +++ b/apps/web/src/components/DataAccessPolicyPanel.vue @@ -1,16 +1,28 @@ @@ -204,11 +269,81 @@ onMounted(() => { :import-sources="importSources" :import-sources-ready="importSourcesReady" :import-sources-failed="importSourcesFailed" - :disabled="disabled" + :disabled="disabled || dataLocked" :compact="compact" @update:model-value="setSection(s, $event)" /> + + + + این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند. + در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست. + اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است. + + + + مثل نقش + سفارشی برای این کاربر + + + + + - + \ No newline at end of file diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index 44f1cbd..653dbee 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, @@ -334,8 +335,13 @@ async function removeTeam(id: string) { } } +const showTreasury = computed(() => { + const modules = auth.user?.enabledModules; + return !modules || modules.includes('treasury'); +}); const expandedAccess = ref(null); const draftAccess = ref({}); +const draftTreasury = ref(null); const accessMode = ref<'inherit' | 'custom'>('inherit'); const accessSaving = ref(false); @@ -378,6 +384,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; } @@ -388,8 +395,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; @@ -585,7 +592,7 @@ onMounted(() => { - پیش‌فرض از نقش است؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید. + مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید. { diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index c15969a..caceb33 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -121,6 +121,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal { assigneeNames, ownerId: item.assignee?.id ?? null, assigneeIds: (item.assignees ?? []).map((a) => a.id), + priority: item.priority, }; } @@ -254,40 +255,54 @@ function onDragOver(stageId: string, event: DragEvent) { async function moveCard(activityId: string, toStageId: string, index?: number) { const fromStageId = findStageOf(activityId); - if (!fromStageId || fromStageId === toStageId) return; + if (!fromStageId) return; const from = columns.value[fromStageId]!; const to = columns.value[toStageId]; if (!to) return; - const item = from.items.find((i) => i.id === activityId)!; + const fromIndex = from.items.findIndex((i) => i.id === activityId); + const item = from.items[fromIndex]; + if (!item) return; const toStage = props.stages.find((s) => s.id === toStageId)!; + const same = fromStageId === toStageId; + const without = (same ? from.items : to.items).filter((i) => i.id !== activityId); + if (same && index == null) return; + let insertAt = index == null || index < 0 ? 0 : index; + if (same && index != null && index > fromIndex) insertAt -= 1; + insertAt = Math.max(0, Math.min(insertAt, without.length)); + if (same && insertAt === fromIndex) return; - const snapshot = { - fromItems: from.items, - fromTotal: from.total, - toItems: to.items, - toTotal: to.total, - }; + const prevFrom = from.items; + const prevTo = to.items; + const prevFromTotal = from.total; + const prevToTotal = to.total; const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId }; - from.items = from.items.filter((i) => i.id !== activityId); - from.total = Math.max(0, from.total - 1); - const nextTo = [...to.items]; - const at = index == null || index < 0 || index > nextTo.length ? 0 : index; - nextTo.splice(at, 0, optimistic); - to.items = nextTo; - to.total += 1; + const nextTo = [...without]; + nextTo.splice(insertAt, 0, optimistic); + if (same) { + from.items = nextTo; + } else { + from.items = from.items.filter((i) => i.id !== activityId); + from.total = Math.max(0, from.total - 1); + to.items = nextTo; + to.total += 1; + } moving.value = true; try { const { data } = await api.patch(`/activities/${activityId}/stage`, { stageId: toStageId, + position: insertAt, }); - to.items = to.items.map((i) => (i.id === activityId ? data : i)); + const column = same ? from : to; + column.items = column.items.map((i) => (i.id === activityId ? data : i)); emit('changed'); } catch (e) { - from.items = snapshot.fromItems; - from.total = snapshot.fromTotal; - to.items = snapshot.toItems; - to.total = snapshot.toTotal; + from.items = prevFrom; + from.total = prevFromTotal; + if (!same) { + to.items = prevTo; + to.total = prevToTotal; + } emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); } finally { moving.value = false; diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 95a18a1..c792160 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -4,6 +4,7 @@ import { CalendarOutlined, CheckOutlined, DeleteOutlined, + FlagFilled, MoreOutlined, } from '@ant-design/icons-vue'; import { @@ -13,10 +14,12 @@ import { Flex as AFlex, Space as ASpace, Tag as ATag, + Tooltip as ATooltip, Typography, theme, } from 'ant-design-vue'; import { userAvatarSrc } from '@/lib/userAvatars'; +import type { ActivityPriority } from '@mento/shared'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board'; @@ -60,6 +63,16 @@ const extraAssignees = computed(() => Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1), ); const showTrash = computed(() => props.selectMode && props.selected); +const PRIORITY_FLAG: Record = { + very_high: { label: 'خیلی زیاد', color: '#dc2626' }, + high: { label: 'زیاد', color: '#ea580c' }, + medium: { label: 'متوسط', color: '#eab308' }, + low: { label: 'کم', color: '#16a34a' }, + very_low: { label: 'خیلی کم', color: '#94a3b8' }, +}; +const priorityFlag = computed(() => + props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null, +); const cardStyle = computed(() => ({ cursor: props.selectMode ? 'pointer' : 'grab', @@ -194,6 +207,11 @@ const cardStyle = computed(() => ({ {{ money.unit }} + + + + + diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue index b8f47b7..d77b035 100644 --- a/apps/web/src/components/pipeline/PipelineColumn.vue +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -13,7 +13,7 @@ import { import AddDealButton from './AddDealButton.vue'; import DealCard from './DealCard.vue'; import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; -import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board'; +import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board'; const props = withDefaults( defineProps<{ @@ -115,7 +115,7 @@ function onCardDragOver(stageId: string, event: DragEvent) { function onCardDrop(stageId: string, index: number, event: DragEvent) { event.preventDefault(); event.stopPropagation(); - emit('drop', stageId, event, index); + emit('drop', stageId, event, pointerDropIndex(index, event)); } function startRename() { diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index 42bac75..5b14b7c 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -3,30 +3,30 @@ import { computed } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { Menu as AMenu } from 'ant-design-vue'; import type { MenuProps } 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(() => [ - { to: '/finance', label: 'داشبورد', exact: true }, - { to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' }, - { to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' }, - { to: '/finance/receivables', label: 'اقساط و مطالبات' }, - { to: '/finance/accounts', label: 'صندوق و حساب‌ها' }, - ...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []), - { to: '/finance/recurring', label: 'تکرارشونده' }, - { to: '/finance/reports', label: 'گزارش' }, - { to: '/finance/audit', label: 'سوابق' }, - { to: '/finance/categories', label: 'دسته‌بندی‌ها' }, -]); +const links = computed(() => { + const role = auth.user?.role; + const permissions = auth.user?.permissions; + const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [ + { to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' }, + { to: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' }, + { to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' }, + { to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' }, + { to: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' }, + { to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' }, + { to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' }, + { to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' }, + { to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' }, + { to: '/finance/categories', label: 'دسته‌بندی‌ها', section: 'treasury_categories' }, + ]; + return all.filter((link) => treasurySectionAllowed(role, permissions, link.section)); +}); function on(path: string, exact?: boolean) { if (exact) return route.path === path; diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index ba60834..023f933 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -19,6 +19,7 @@ import type { NotificationsListDto, } from '@mento/shared'; import { canManageSettings, canViewAllRecords } from '@/lib/roles'; +import { hasSectionPermission, sectionPermissionForPath } from '@/lib/section-permissions'; import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat'; const auth = useAuthStore(); @@ -167,12 +168,24 @@ 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; - if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false; + || 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) + : true; + if (!hasSection) return false; + // لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز می‌شود + const grantedBySection = section === 'audit' && hasSection; + if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) { + return false; + } if (!moduleAllowed(card.module)) return false; if ( card.admin && diff --git a/apps/web/src/lib/lead-form-notes.ts b/apps/web/src/lib/lead-form-notes.ts index fd72c81..0ffdae7 100644 --- a/apps/web/src/lib/lead-form-notes.ts +++ b/apps/web/src/lib/lead-form-notes.ts @@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView } export function leadNoteFileIsImage(file: LeadNoteFile): boolean { - return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/'); + return ( + file.mime.startsWith('image/') || + (file.dataUrl ?? '').startsWith('data:image/') || + /\.(png|jpe?g|gif|webp)$/i.test(file.name) + ); +} + +export type NoteChip = + | { kind: 'text'; text: string } + | { kind: 'image'; src: string; name: string } + | { kind: 'link'; href: string; label: string }; + +const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi; +const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi; +const HTTP_URL = /https?:\/\/[^\s<>"']+/gi; + +/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا می‌کند. */ +export function noteChips(raw: string | null | undefined): NoteChip[] { + const text = raw ?? ''; + if (!text.trim()) return []; + const chips: NoteChip[] = []; + const token = new RegExp( + `${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`, + 'gi', + ); + let cursor = 0; + for (const match of text.matchAll(token)) { + const index = match.index ?? 0; + pushText(chips, text.slice(cursor, index)); + chips.push(chipForToken(match[0])); + cursor = index + match[0].length; + } + pushText(chips, text.slice(cursor)); + return chips; +} + +function pushText(chips: NoteChip[], value: string) { + if (!value) return; + const prev = chips[chips.length - 1]; + if (prev?.kind === 'text') prev.text += value; + else chips.push({ kind: 'text', text: value }); +} + +function chipForToken(token: string): NoteChip { + if (token.startsWith('{')) { + const file = readFileValue(token); + if (file?.dataUrl && leadNoteFileIsImage(file)) { + return { kind: 'image', src: file.dataUrl, name: file.name }; + } + if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name }; + return { kind: 'text', text: file?.name || 'فایل پیوست' }; + } + if (token.startsWith('data:image/')) { + return { kind: 'image', src: token, name: 'تصویر پیوست' }; + } + if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) }; + return { kind: 'link', href: token, label: shortUrl(token) }; +} + +function isImageUrl(href: string): boolean { + return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href); +} + +function shortUrl(href: string): string { + try { + const url = new URL(href); + const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? ''); + const label = file || url.hostname; + return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label; + } catch { + return href.length > 42 ? `${href.slice(0, 28)}…` : href; + } } /** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */ @@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null { return asFile(JSON.parse(value)); } catch { const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim(); - if (name) return { name, mime: '', dataUrl: null }; + const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? ''; + if (name) return { name, mime, dataUrl: null }; if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null }; return null; } diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts index 93913a1..4df4486 100644 --- a/apps/web/src/lib/pipeline-board.ts +++ b/apps/web/src/lib/pipeline-board.ts @@ -1,5 +1,6 @@ import { currencySuffix } from '@/lib/money'; import { matchesSearch } from '@/lib/search'; +import type { ActivityPriority } from '@mento/shared'; export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate'; @@ -21,6 +22,8 @@ export type PipelineDeal = { assigneeIds?: string[]; /** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */ searchFields?: Array; + /** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */ + priority?: ActivityPriority; }; export type PipelineUiCopy = { @@ -344,18 +347,16 @@ export function updateDealStage( return without.map((stage) => { if (stage.id !== stageId) return stage; const next = [...stage.deals]; - let insertAt = - toIndex == null || toIndex < 0 || toIndex > next.length - ? next.length - : toIndex; + let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex; if ( fromStageId === stageId && fromIndex >= 0 && toIndex != null && toIndex > fromIndex ) { - insertAt = Math.max(0, insertAt - 1); + insertAt -= 1; } + insertAt = Math.max(0, Math.min(insertAt, next.length)); next.splice(insertAt, 0, { ...moving!, stageId }); return { ...stage, deals: next }; }); @@ -422,6 +423,14 @@ export function toFaDealCount(n: number): string { } /** جابه‌جایی یک ستون به ایندکس جدید */ +/** نیمهٔ پایین کارت یعنی «بعد از این کارت». ایندکس نسبت به فهرست فعلی ستون است. */ +export function pointerDropIndex(index: number, event: DragEvent): number { + const el = event.currentTarget as HTMLElement | null; + const rect = el?.getBoundingClientRect(); + if (!rect || rect.height <= 0) return index; + return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0); +} + export function moveStageToIndex( stages: PipelineStage[], stageId: string, 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/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 383c9e1..b5101e1 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth'; import { canAssignToOthers } from '@/lib/assign-access'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { brand } from '@mento/shared'; +import { + leadNoteFileIsImage, + noteChips, + parseLeadFormNotes, + type LeadNoteFile, + type LeadNoteRow, +} from '@/lib/lead-form-notes'; const route = useRoute(); const router = useRouter(); @@ -62,6 +69,51 @@ const moreOpen = ref(false); const commentOpen = ref(false); const postponeOpen = ref(false); const noteOpen = ref(true); +const noteModel = computed(() => parseLeadFormNotes(activity.value?.description)); +const noteRows = computed(() => + noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]), +); +const noteExtra = computed(() => + noteChips( + noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description, + ).filter( + (chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'), + ), +); +const recoveredImages = ref>({}); + +function noteImageSrc(file: LeadNoteFile): string | null { + if (!leadNoteFileIsImage(file)) return null; + return file.dataUrl || recoveredImages.value[file.name] || null; +} + +watch( + [() => activity.value?.id, () => activity.value?.lead?.id, noteRows], + async ([activityId, leadId]) => { + const missing = noteRows.value + .map((row) => row.file) + .filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file))) + .map((file) => file.name); + recoveredImages.value = {}; + if (!activityId || !leadId || !missing.length) return; + try { + const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`); + if (activity.value?.id !== activityId) return; + const wanted = new Set(missing); + const next: Record = {}; + for (const row of parseLeadFormNotes(data.notes).rows) { + const file = row.file; + if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) { + next[file.name] = file.dataUrl; + } + } + recoveredImages.value = next; + } catch { + recoveredImages.value = {}; + } + }, + { immediate: true }, +); const users = ref([]); const assignUserIds = ref([]); @@ -565,8 +617,61 @@ onMounted(() => {
-

{{ activity.description.trim() }}

-

یادداشتی ثبت نشده.

+
+
+
{{ row.label }}
+
+ + {{ row.file.name }} + +
+
+
+

یادداشتی ثبت نشده.

+

+ +

{ margin: 0; display: grid; gap: 0.9rem; - padding-bottom: 5.5rem; + padding-bottom: 1rem; + overflow-x: clip; +} + +.page > * { + min-width: 0; + max-width: 100%; } .nav { @@ -876,6 +987,8 @@ dt { dd { font-weight: 700; text-align: start; + min-width: 0; + overflow-wrap: anywhere; } .lead-link { @@ -908,17 +1021,27 @@ dd { .assignees-row dd { flex: 1 1 16rem; min-width: 0; + max-width: 100%; font-weight: 500; } +.assignees-row dd :deep(.ant-select) { + width: 100%; + max-width: 100%; +} + +.assignees-row dd :deep(.ant-select-selection-overflow) { + flex-wrap: wrap; +} + .flag.low, .priority.low { - color: #64748b; + color: #16a34a; } .flag.medium, .priority.medium { - color: #ea580c; + color: #eab308; } .flag.high, @@ -968,12 +1091,53 @@ dd { line-height: 1.9; font-size: 0.9rem; white-space: pre-line; + overflow-wrap: anywhere; } .note p.empty { color: #94a3b8; } +.note-rows { + margin: 0.75rem 0 0; + display: grid; + gap: 0.55rem; +} + +.note-rows > div { + display: grid; + grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr); + gap: 0.45rem 0.75rem; + align-items: start; +} + +.note-rows dt { + color: #64748b; + font-weight: 500; +} + +.note-rows dd { + display: grid; + gap: 0.35rem; + justify-items: start; +} + +.note-shot { + display: block; + max-width: 100%; +} + +.note-shot img { + display: block; + width: auto; + max-width: min(280px, 100%); + max-height: 220px; + object-fit: contain; + border-radius: 0.6rem; + border: 1px solid #e2e8f0; + background: #fff; +} + .note-photos { display: flex; flex-wrap: wrap; @@ -1017,13 +1181,11 @@ dd { } .dock { - position: fixed; - inset-inline: 0.85rem; - bottom: calc(0.55rem + var(--mobile-tabbar-h)); + position: static; display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 0.5rem; - z-index: 8; + width: 100%; } .act { @@ -1053,15 +1215,9 @@ dd { cursor: not-allowed; } -@media (min-width: 900px) { +@media (max-width: 899px) { .page { - padding-bottom: 0; - } - - .dock { - position: sticky; - inset-inline: auto; - bottom: 0; + padding-bottom: calc(1rem + var(--mobile-tabbar-h)); } } diff --git a/apps/web/src/views/LeadBoardView.vue b/apps/web/src/views/LeadBoardView.vue index 2439109..c159b7f 100644 --- a/apps/web/src/views/LeadBoardView.vue +++ b/apps/web/src/views/LeadBoardView.vue @@ -32,6 +32,7 @@ import { import { api } from '@/lib/api'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { canManageSettings } from '@/lib/roles'; +import { pointerDropIndex } from '@/lib/pipeline-board'; import { useAuthStore } from '@/stores/auth'; import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared'; @@ -481,28 +482,41 @@ function onDragOver(stageId: string, event: DragEvent) { if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } -async function onDrop(stageId: string, event: DragEvent) { +async function onDrop(stageId: string, event: DragEvent, index?: number) { event.preventDefault(); + event.stopPropagation(); const leadId = event.dataTransfer?.getData('text/plain') || draggingId.value || ''; draggingId.value = null; dropStageId.value = null; - if (!leadId) return; + if (!leadId || moving.value) return; const fromCol = columns.value.find((c) => c.leads.some((l) => l.id === leadId), ); - if (!fromCol || fromCol.stage.id === stageId) return; - - const lead = fromCol.leads.find((l) => l.id === leadId); + const target = columns.value.find((c) => c.stage.id === stageId); + if (!fromCol || !target) return; + const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId); + const lead = fromCol.leads[fromIndex]; if (!lead) return; + const same = fromCol.stage.id === stageId; + if (same && index == null) return; + const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId); + let insertAt = index == null || index < 0 ? 0 : index; + if (same && index != null && index > fromIndex) insertAt -= 1; + insertAt = Math.max(0, Math.min(insertAt, without.length)); + if (same && insertAt === fromIndex) return; + const nextLeads = [...without]; + nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name }); + const prev = columns.value.map((c) => ({ ...c, leads: [...c.leads], })); columns.value = columns.value.map((c) => { + if (same && c.stage.id === stageId) return { ...c, leads: nextLeads }; if (c.stage.id === fromCol.stage.id) { return { ...c, @@ -511,11 +525,7 @@ async function onDrop(stageId: string, event: DragEvent) { }; } if (c.stage.id === stageId) { - return { - ...c, - total: c.total + 1, - leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads], - }; + return { ...c, total: c.total + 1, leads: nextLeads }; } return c; }); @@ -523,7 +533,7 @@ async function onDrop(stageId: string, event: DragEvent) { moving.value = true; error.value = ''; try { - await api.patch(`/leads/${leadId}/move`, { stageId }); + await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt }); } catch { columns.value = prev; error.value = 'جابه‌جایی لید ناموفق بود.'; @@ -644,9 +654,13 @@ onMounted(load);
- + +
s.deals.some((d) => d.id === dealId)); - const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1; - if (current?.id === stageId && (index == null || index === currentIndex)) { - return; - } + const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId)); + if (current?.id === stageId && index == null) return; + + const next = updateDealStage(stages.value, dealId, stageId, index); + const unchanged = next.every((stage, stageIndex) => { + const prev = stages.value[stageIndex]; + return ( + stage.deals.length === (prev?.deals.length ?? -1) && + stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id) + ); + }); + if (unchanged) return; await applyUpdateDealStage(dealId, stageId, index); } diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 1bd8784..914dd70 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -13,6 +13,7 @@ import { DATA_ACCESS_SECTIONS, ROLE_PERMISSION_ITEMS, type DataAccessPolicy, + type FinancialAccountDto, type InviteUserRequest, type OrganizationDto, type OrgModuleKey, @@ -159,7 +160,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'], }; @@ -223,6 +233,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); @@ -693,7 +707,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() { @@ -713,7 +731,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) { @@ -721,6 +772,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]; @@ -744,12 +803,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; @@ -1661,14 +1724,53 @@ onUnmounted(() => { + + تیک مدیریت مالی در را باز می‌کند. بخش‌های ریز جدا انتخاب می‌شوند و با روشن کردن مدیریت مالی خودبه‌خود تیک نمی‌خورند. + تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخش‌ها به‌جز حقوق باز می‌مانند. با ذخیره حتی یک بخش، فقط همان‌ها می‌مانند. حقوق جداست. + + + + + + + + این بخش جدا از تیک بخش‌های مالی است. می‌توانید حساب‌هایی را بدهید که خود عضو مسئول آن‌هاست، + و جدا از آن حساب‌های دیگر را هم انتخاب کنید. اگر هیچ‌کدام انتخاب نشود، همه حساب‌ها باز می‌ماند. + - {{ perm.label }} + حساب‌هایی که خودش مسئول آن‌هاست + حساب‌های دیگر + + + {{ account.name }} + + + + حسابی برای انتخاب نیست. + diff --git a/apps/web/src/views/treasury/TreasuryAccountsView.vue b/apps/web/src/views/treasury/TreasuryAccountsView.vue index ba17e20..66e05d6 100644 --- a/apps/web/src/views/treasury/TreasuryAccountsView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountsView.vue @@ -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([]); +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 45864aa..99db24a 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 {