From 7ae6a5837ac1c37ef23e33321610b95abb64571d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 13:45:16 +0330 Subject: [PATCH 01/65] feat: enforce role section permissions and add a leads permission Unticking a section hides it from the menu, closes its routes and blocks its writes on the server; existing roles keep their current access via migration. --- .../migration.sql | 14 ++++ .../src/activities/activities.controller.ts | 4 +- .../src/audit-logs/audit-logs.controller.ts | 6 +- apps/api/src/auth/section-permission.guard.ts | 73 +++++++++++++++++++ apps/api/src/contacts/companies.controller.ts | 4 +- apps/api/src/contacts/people.controller.ts | 4 +- .../api/src/inventory/inventory.controller.ts | 4 +- apps/api/src/invoices/invoices.controller.ts | 4 +- apps/api/src/leads/leads.controller.ts | 4 +- apps/api/src/notes/notes.controller.ts | 4 +- apps/api/src/org-roles/role-defaults.ts | 2 +- apps/api/src/pipelines/deals.controller.ts | 4 +- .../api/src/pipelines/pipelines.controller.ts | 4 +- apps/api/src/products/products.controller.ts | 4 +- apps/api/src/quotes/quotes.controller.ts | 4 +- apps/api/src/reports/reports.controller.ts | 6 +- apps/api/src/sales/sales.controller.ts | 4 +- apps/web/src/layouts/AppLayout.vue | 51 +++++++++++-- apps/web/src/lib/section-permissions.ts | 49 +++++++++++++ apps/web/src/router/index.ts | 8 ++ apps/web/src/views/AuditLogsView.vue | 8 +- apps/web/src/views/TeamSettingsView.vue | 1 + packages/shared/src/index.ts | 4 +- 23 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql create mode 100644 apps/api/src/auth/section-permission.guard.ts create mode 100644 apps/web/src/lib/section-permissions.ts diff --git a/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql b/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql new file mode 100644 index 0000000..e53d62d --- /dev/null +++ b/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql @@ -0,0 +1,14 @@ +-- تیک‌های مجوز نقش از این نسخه واقعاً اعمال می‌شوند. +-- تا امروز همهٔ نقش‌ها عملاً به این بخش‌ها دسترسی داشتند؛ همان دسترسی را برای نقش‌های موجود حفظ می‌کنیم. +UPDATE "OrgRole" +SET "permissions" = ARRAY( + SELECT DISTINCT p + FROM unnest( + "permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports'] + ) AS p +); + +-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ می‌کنیم. +UPDATE "OrgRole" +SET "permissions" = array_remove("permissions", 'audit') +WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin'); diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 481c9d2..38696cb 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -50,7 +51,8 @@ const excelUpload = FileInterceptor('file', { }); @Controller('activities') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('activities') export class ActivitiesController { constructor( private readonly activitiesService: ActivitiesService, diff --git a/apps/api/src/audit-logs/audit-logs.controller.ts b/apps/api/src/audit-logs/audit-logs.controller.ts index 38e93ce..8720d76 100644 --- a/apps/api/src/audit-logs/audit-logs.controller.ts +++ b/apps/api/src/audit-logs/audit-logs.controller.ts @@ -5,6 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { OrgModuleGuard } from '../billing/org-module.guard'; import { RequireOrgModule } from '../billing/org-module.decorator'; import { AuditLogsService } from './audit-logs.service'; @@ -42,9 +43,9 @@ class ListAuditQueryDto { } @Controller('audit-logs') -@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) +@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard) @RequireOrgModule('audit_logs') -@Roles('owner', 'admin') +@RequirePermission('audit', { reads: true }) export class AuditLogsController { constructor( private readonly auditLogs: AuditLogsService, @@ -52,6 +53,7 @@ export class AuditLogsController { ) {} @Get('attendance') + @Roles('owner', 'admin') attendanceList( @CurrentUser() user: { organizationId: string }, @Query() query: ListAuditQueryDto, diff --git a/apps/api/src/auth/section-permission.guard.ts b/apps/api/src/auth/section-permission.guard.ts new file mode 100644 index 0000000..49b8c82 --- /dev/null +++ b/apps/api/src/auth/section-permission.guard.ts @@ -0,0 +1,73 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { RolePermissionKey } from '@mento/shared'; +import { ROLE_PERMISSION_ITEMS } from '@mento/shared'; +import type { Actor } from '../common/record-scope'; +import { PrismaService } from '../prisma/prisma.service'; + +const SECTION_PERMISSION_KEY = 'sectionPermission'; + +type SectionPermissionMeta = { + key: RolePermissionKey; + /** true: خواندن هم مجوز می‌خواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */ + reads: boolean; +}; + +/** + * مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند. + * پیش‌فرض فقط مدیریت را می‌بندد تا انتخاب رکورد در فرم‌های بخش‌های دیگر کار کند. + */ +export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) => + SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta); + +function isReadRequest(method: string, url: string): boolean { + if (method !== 'GET' && method !== 'HEAD') return false; + const path = (url.split('?')[0] || '').replace(/\/+$/, ''); + return !path.endsWith('/export'); +} + +@Injectable() +export class SectionPermissionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const meta = this.reflector.getAllAndOverride( + SECTION_PERMISSION_KEY, + [context.getHandler(), context.getClass()], + ); + if (!meta) return true; + + const req = context.switchToHttp().getRequest<{ + user?: Actor; + method?: string; + originalUrl?: string; + url?: string; + }>(); + const actor = req.user; + const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key; + if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`); + if (actor.role === 'owner' || actor.role === 'admin') return true; + + const method = (req.method || 'GET').toUpperCase(); + if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true; + + const membership = await this.prisma.membership.findFirst({ + where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, + select: { orgRole: { select: { permissions: true } } }, + }); + const permissions = membership?.orgRole?.permissions ?? []; + if (!permissions.includes(meta.key)) { + throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`); + } + return true; + } +} diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 6741eb9..893a4c8 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { CompaniesService } from './companies.service'; @@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', { }); @Controller('companies') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('customers') export class CompaniesController { constructor( private readonly companiesService: CompaniesService, diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index a440b98..c8fad31 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { OrgModuleGuard } from '../billing/org-module.guard'; @@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', { }); @Controller('people') -@UseGuards(JwtAuthGuard, OrgModuleGuard) +@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard) @RequireOrgModule('customers') +@RequirePermission('customers') export class PeopleController { constructor( private readonly peopleService: PeopleService, diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts index 5e8873f..fff2023 100644 --- a/apps/api/src/inventory/inventory.controller.ts +++ b/apps/api/src/inventory/inventory.controller.ts @@ -9,6 +9,7 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; @@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service'; type AuthUser = { organizationId: string }; @Controller('inventory') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard) +@RequirePermission('products') export class InventoryController { constructor(private readonly inventory: InventoryService) {} diff --git a/apps/api/src/invoices/invoices.controller.ts b/apps/api/src/invoices/invoices.controller.ts index 7fc32ce..dec8b37 100644 --- a/apps/api/src/invoices/invoices.controller.ts +++ b/apps/api/src/invoices/invoices.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreateInvoiceDto, @@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service'; type AuthUser = { organizationId: string; userId: string; role: string }; @Controller('invoices') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('billing') export class InvoicesController { constructor(private readonly invoicesService: InvoicesService) {} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 8077c29..26b3919 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -46,10 +46,12 @@ import { import { LeadStagesService } from './lead-stages.service'; import { LeadsImportService } from './leads-import.service'; import { LeadsService } from './leads.service'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; @Controller('leads') -@UseGuards(JwtAuthGuard, OrgModuleGuard) +@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard) @RequireOrgModule('leads') +@RequirePermission('leads', { reads: true }) export class LeadsController { constructor( private readonly leads: LeadsService, diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 942790f..22749c0 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -15,6 +15,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto'; import { NoteAiService } from './note-ai.service'; @@ -30,7 +31,8 @@ type AuthUser = { }; @Controller('notes') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('notes') export class NotesController { constructor( private readonly notesService: NotesService, diff --git a/apps/api/src/org-roles/role-defaults.ts b/apps/api/src/org-roles/role-defaults.ts index 38da870..fa1fefe 100644 --- a/apps/api/src/org-roles/role-defaults.ts +++ b/apps/api/src/org-roles/role-defaults.ts @@ -41,7 +41,7 @@ export const SYSTEM_ROLE_SEEDS: Array<{ name: 'کارشناس فروش', description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.', color: 'teal', - permissions: [...ALL_ROLE_PERMISSIONS], + permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'), }, { systemKey: 'support', diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index aa803f3..e609ba3 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { OrgModuleGuard } from '../billing/org-module.guard'; @@ -26,8 +27,9 @@ import { DealsExcelService } from './deals-excel.service'; import { brand } from '@mento/shared'; @Controller('deals') -@UseGuards(JwtAuthGuard, OrgModuleGuard) +@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard) @RequireOrgModule('pipeline') +@RequirePermission('sales') export class DealsController { constructor( private readonly dealsService: DealsService, diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index aca5697..e7d26f8 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; @@ -24,7 +25,8 @@ import { import { PipelinesService } from './pipelines.service'; @Controller('pipelines') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard) +@RequirePermission('sales') export class PipelinesController { constructor(private readonly pipelinesService: PipelinesService) {} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index cf46dc7..3838c2f 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -17,6 +17,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; @@ -27,7 +28,8 @@ import { ProductsExcelService } from './products-excel.service'; import { brand } from '@mento/shared'; @Controller('products') -@UseGuards(JwtAuthGuard, RolesGuard) +@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard) +@RequirePermission('products') export class ProductsController { constructor( private readonly productsService: ProductsService, diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts index ad86f98..e54f612 100644 --- a/apps/api/src/quotes/quotes.controller.ts +++ b/apps/api/src/quotes/quotes.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreateQuoteDto, @@ -21,7 +22,8 @@ import { QuotesService } from './quotes.service'; type AuthUser = { organizationId: string; userId: string; role: string }; @Controller('quotes') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('billing') export class QuotesController { constructor(private readonly quotesService: QuotesService) {} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index d622da8..597db8a 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -17,6 +17,7 @@ import { MaxLength, } from 'class-validator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { SavedViewsService } from '../saved-views/saved-views.service'; @@ -52,7 +53,8 @@ class UpdateReportViewDto { } @Controller('reports') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('reports', { reads: true }) export class ReportsController { constructor( private readonly reportsService: ReportsService, @@ -95,7 +97,9 @@ export class ReportsController { }); } + /** داشبورد صفحهٔ خانه برای همه باز است */ @Get('home') + @RequirePermission('reports', { reads: false }) home(@CurrentUser() user: Actor) { return this.reportsService.home(user); } diff --git a/apps/api/src/sales/sales.controller.ts b/apps/api/src/sales/sales.controller.ts index 99eb699..4d495b1 100644 --- a/apps/api/src/sales/sales.controller.ts +++ b/apps/api/src/sales/sales.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreateSalesGoalDto, @@ -20,7 +21,8 @@ import { import { SalesService } from './sales.service'; @Controller('sales') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, SectionPermissionGuard) +@RequirePermission('sales') export class SalesController { constructor(private readonly salesService: SalesService) {} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 7e42ca0..e8f22d5 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -19,6 +19,11 @@ import type { NotificationsListDto, } from '@mento/shared'; import { canManageSettings, canViewAllRecords } from '@/lib/roles'; +import { + canOpenPath, + hasSectionPermission, + sectionPermissionForPath, +} from '@/lib/section-permissions'; import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat'; const auth = useAuthStore(); @@ -145,6 +150,11 @@ const moreSectionActive = computed( const enabledModules = computed(() => auth.user?.enabledModules ?? null); const subscription = computed(() => auth.user?.subscription ?? null); +/** لینک‌های مستقیم منو (بیرون از هاب‌ها) با مجوز بخش */ +function canOpen(path: string) { + return canOpenPath(auth.user?.role, auth.user?.permissions, path); +} + function moduleAllowed(module?: string) { if (!module) return true; if (!enabledModules.value) return true; @@ -163,7 +173,16 @@ function cardAllowedForNav(card: { || 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; + 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 && @@ -607,6 +626,7 @@ onUnmounted(() => { { باشگاه مشتریان - + گزارش‌ها @@ -807,14 +833,24 @@ onUnmounted(() => {
- + - + @@ -897,6 +933,7 @@ onUnmounted(() => { داشبورد + + { + +
+ +

+ در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید + ورود دوباره تا پایان همان روز را هم ببندید. +

+
+ +
+
@@ -1875,22 +1966,53 @@ tbody tr + tr td { color: #94a3b8; } -.logout-time { - width: 7.2rem; - min-height: 2rem; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 0 0.45rem; - font: inherit; - background: #fff; - box-sizing: border-box; +.logout-cell { + display: inline-flex; + align-items: center; + gap: 0.3rem; } -.logout-time:disabled { - background: #f8fafc; +.logout-btn, +.block-btn { + display: inline-grid; + place-items: center; + height: 1.9rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + color: #64748b; + font: inherit; + cursor: pointer; +} + +.logout-btn { + min-width: 1.9rem; + padding: 0 0.4rem; +} + +.logout-btn.set { + color: #0f172a; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.logout-btn:hover, +.block-btn:hover { + border-color: #0d9488; + color: #0f766e; +} + +.block-btn { + width: 1.9rem; color: #94a3b8; } +.block-btn.on { + border-color: #99f6e4; + background: #f0fdfa; + color: #0f766e; +} + .roles-grid, .roles-tree { display: grid; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ebbadbc..16f22d3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -378,6 +378,8 @@ export interface OrgUserDto { priorArchiveEnabled?: boolean; /** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */ logoutTime?: string | null; + /** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */ + blockLoginAfterLogout?: boolean; createdAt: string; } From 29d99cbded789cfe9a57a5ad77da302957b370bd Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 17:16:31 +0330 Subject: [PATCH 03/65] feat: customer groups for people and companies with tahesab group sync and bulk register Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export --- .../migration.sql | 85 +++++ apps/api/prisma/schema.prisma | 49 +++ apps/api/src/app.module.ts | 2 + apps/api/src/contacts/companies.controller.ts | 1 + apps/api/src/contacts/companies.service.ts | 33 +- .../src/contacts/contacts-excel.service.ts | 34 +- apps/api/src/contacts/contacts.module.ts | 2 + apps/api/src/contacts/dto.ts | 21 ++ apps/api/src/contacts/people.controller.ts | 2 + apps/api/src/contacts/people.service.ts | 25 ++ .../customer-groups.controller.ts | 75 +++++ .../customer-groups/customer-groups.module.ts | 12 + .../customer-groups.service.ts | 309 ++++++++++++++++++ apps/api/src/integrations/tahesab/dto.ts | 30 ++ .../tahesab/person-link.service.ts | 234 ++++++++++++- .../tahesab/tahesab.controller.ts | 29 ++ .../integrations/tahesab/tahesab.module.ts | 3 +- .../web/src/components/CustomerGroupChips.vue | 48 +++ .../src/components/CustomerGroupPicker.vue | 68 ++++ .../src/components/CustomerGroupsManager.vue | 295 +++++++++++++++++ apps/web/src/lib/customer-groups.ts | 53 +++ apps/web/src/views/CompanyFormView.vue | 8 +- apps/web/src/views/ContactsView.vue | 89 ++++- apps/web/src/views/PersonFormView.vue | 24 ++ apps/web/src/views/PersonProfileView.vue | 8 +- apps/web/src/views/TahesabSettingsView.vue | 167 ++++++++++ packages/shared/src/index.ts | 48 +++ 27 files changed, 1728 insertions(+), 26 deletions(-) create mode 100644 apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql create mode 100644 apps/api/src/customer-groups/customer-groups.controller.ts create mode 100644 apps/api/src/customer-groups/customer-groups.module.ts create mode 100644 apps/api/src/customer-groups/customer-groups.service.ts create mode 100644 apps/web/src/components/CustomerGroupChips.vue create mode 100644 apps/web/src/components/CustomerGroupPicker.vue create mode 100644 apps/web/src/components/CustomerGroupsManager.vue create mode 100644 apps/web/src/lib/customer-groups.ts diff --git a/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql b/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql new file mode 100644 index 0000000..90bac5b --- /dev/null +++ b/apps/api/prisma/migrations/20261021170000_customer_groups/migration.sql @@ -0,0 +1,85 @@ +-- CreateTable +CREATE TABLE "CustomerGroup" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT 'teal', + "position" INTEGER NOT NULL DEFAULT 0, + "tahesabConnectionId" TEXT, + "tahesabGid" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CustomerGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PersonGroupLink" ( + "personId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'manual', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PersonGroupLink_pkey" PRIMARY KEY ("personId","groupId") +); + +-- CreateTable +CREATE TABLE "CompanyGroupLink" ( + "companyId" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CompanyGroupLink_pkey" PRIMARY KEY ("companyId","groupId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CustomerGroup_organizationId_name_key" ON "CustomerGroup"("organizationId", "name"); +CREATE INDEX "CustomerGroup_organizationId_position_idx" ON "CustomerGroup"("organizationId", "position"); +CREATE INDEX "CustomerGroup_tahesabConnectionId_tahesabGid_idx" ON "CustomerGroup"("tahesabConnectionId", "tahesabGid"); +CREATE INDEX "PersonGroupLink_groupId_idx" ON "PersonGroupLink"("groupId"); +CREATE INDEX "CompanyGroupLink_groupId_idx" ON "CompanyGroupLink"("groupId"); + +-- AddForeignKey +ALTER TABLE "CustomerGroup" ADD CONSTRAINT "CustomerGroup_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PersonGroupLink" ADD CONSTRAINT "PersonGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CompanyGroupLink" ADD CONSTRAINT "CompanyGroupLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "CustomerGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- تبدیل داده‌های قبلی: «گروه مشتری» اکسل (customFields.group) و «گروه‌بندی شرکت» (groupName). +-- مقدارهای چندتایی با «،» «,» «/» جدا می‌شوند. برچسب‌های «وضعیت مشتری» که دیدار در همان ستون می‌گذارد گروه حساب نمی‌شوند. +CREATE TEMP TABLE "_cg_src" AS +SELECT p."organizationId" AS org, 'person' AS kind, p."id" AS ref, left(btrim(x), 80) AS name +FROM "Person" p +CROSS JOIN LATERAL regexp_split_to_table(p."customFields"->>'group', '\s*[،,/]\s*') AS x +WHERE COALESCE(p."customFields"->>'group', '') <> '' +UNION ALL +SELECT c."organizationId", 'company', c."id", left(btrim(x), 80) +FROM "Company" c +CROSS JOIN LATERAL regexp_split_to_table(c."groupName", '\s*[،,/]\s*') AS x +WHERE COALESCE(c."groupName", '') <> ''; + +DELETE FROM "_cg_src" +WHERE name = '' + OR name IN ('مشتری تازه وارد', 'مشتری خوشحال', 'مشتری ناراضی', 'مشتری خاص'); + +INSERT INTO "CustomerGroup" ("id", "organizationId", "name", "color", "position", "createdAt", "updatedAt") +SELECT 'cgrp_' || md5(d.org || '|' || d.name), d.org, d.name, 'teal', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM (SELECT DISTINCT org, name FROM "_cg_src") AS d +ON CONFLICT ("organizationId", "name") DO NOTHING; + +INSERT INTO "PersonGroupLink" ("personId", "groupId", "source", "createdAt") +SELECT DISTINCT s.ref, g."id", 'manual', CURRENT_TIMESTAMP +FROM "_cg_src" s +JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name +WHERE s.kind = 'person' +ON CONFLICT DO NOTHING; + +INSERT INTO "CompanyGroupLink" ("companyId", "groupId", "createdAt") +SELECT DISTINCT s.ref, g."id", CURRENT_TIMESTAMP +FROM "_cg_src" s +JOIN "CustomerGroup" g ON g."organizationId" = s.org AND g."name" = s.name +WHERE s.kind = 'company' +ON CONFLICT DO NOTHING; + +DROP TABLE "_cg_src"; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 682fa2b..ad290fb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -160,6 +160,7 @@ model Organization { orgRoles OrgRole[] teams Team[] companies Company[] + customerGroups CustomerGroup[] people Person[] attendanceEvents AttendanceEvent[] discAssessments DiscAssessment[] @@ -477,11 +478,13 @@ model Company { city String? postalCode String? address String? + /// قدیمی؛ از نسخهٔ گروه‌بندی، گروه‌ها در CompanyGroupLink هستند groupName String? notes String? registeredAt DateTime? tags String[] @default([]) customFields Json @default("{}") + groupLinks CompanyGroupLink[] people Person[] deals Deal[] activities Activity[] @@ -505,6 +508,51 @@ model Company { @@index([ownerId]) } +/// گروه مشتری (مشترک بین اشخاص و شرکت‌ها؛ هر مشتری چند گروه) +model CustomerGroup { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + color String @default("teal") + position Int @default(0) + /// گروه متناظر در ته‌حساب (GID) روی همان اتصال + tahesabConnectionId String? + tahesabGid String? + people PersonGroupLink[] + companies CompanyGroupLink[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId, position]) + @@index([tahesabConnectionId, tahesabGid]) +} + +model PersonGroupLink { + personId String + person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + groupId String + group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + /// manual | tahesab — گروهِ ته‌حسابی با همگام‌سازی جایگزین می‌شود؛ دستی دست نمی‌خورد + source String @default("manual") + createdAt DateTime @default(now()) + + @@id([personId, groupId]) + @@index([groupId]) +} + +model CompanyGroupLink { + companyId String + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + groupId String + group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@id([companyId, groupId]) + @@index([groupId]) +} + model Person { id String @id @default(cuid()) organizationId String @@ -541,6 +589,7 @@ model Person { mood PersonMood @default(newcomer) tags String[] @default([]) customFields Json @default("{}") + groupLinks PersonGroupLink[] loyaltyPoints Int @default(0) loyaltyTierId String? loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a99fc5b..6898095 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -54,6 +54,7 @@ import { DeletionsModule } from './deletions/deletions.module'; import { OutboundSmsModule } from './outbound-sms/outbound-sms.module'; import { BehaviorModule } from './behavior/behavior.module'; import { SmsBillingModule } from './sms-billing/sms-billing.module'; +import { CustomerGroupsModule } from './customer-groups/customer-groups.module'; @Module({ imports: [ @@ -65,6 +66,7 @@ import { SmsBillingModule } from './sms-billing/sms-billing.module'; AiModule, PrismaModule, SmsBillingModule, + CustomerGroupsModule, OutboundSmsModule, StorageModule, HealthModule, diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 893a4c8..32dd2e7 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -47,6 +47,7 @@ export class CompaniesController { query.q, query.page ?? 1, query.limit ?? 20, + { groupId: query.groupId }, ); } diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index efdeb3f..70d6b3a 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -27,6 +27,10 @@ import { normalizeTags } from '../common/tags'; import { normalizePersonImportSource } from './person-source'; import { buildSearchWhere, ci } from '../common/search'; import { GamificationService } from '../gamification/gamification.service'; +import { + CustomerGroupsService, + groupSummarySelect, +} from '../customer-groups/customer-groups.service'; function parseDateOrNull(raw?: string | null): Date | null { if (!raw?.trim()) return null; @@ -37,6 +41,10 @@ function parseDateOrNull(raw?: string | null): Date | null { const companyInclude = { _count: { select: { people: true } }, owner: { select: { id: true, fullName: true } }, + groupLinks: { + select: { group: { select: groupSummarySelect } }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + }, } satisfies Prisma.CompanyInclude; type CompanyRow = { @@ -67,6 +75,7 @@ type CompanyRow = { updatedAt: Date; _count: { people: number }; owner: { id: string; fullName: string } | null; + groupLinks: Array<{ group: { id: string; name: string; color: string } }>; }; @Injectable() @@ -74,6 +83,7 @@ export class CompaniesService { constructor( private readonly prisma: PrismaService, private readonly game: GamificationService, + private readonly groups: CustomerGroupsService, ) {} async list( @@ -81,6 +91,7 @@ export class CompaniesService { q?: string, page = 1, limit = 20, + opts?: { groupId?: string }, ): Promise> { const search = buildSearchWhere(q, (t) => [ { name: ci(t) }, @@ -94,10 +105,15 @@ export class CompaniesService { { city: ci(t) }, { province: ci(t) }, ]); + const groupId = opts?.groupId?.trim(); + const and: Prisma.CompanyWhereInput[] = [ + ...(search ? [search] : []), + ...(groupId ? [{ groupLinks: { some: { groupId } } }] : []), + ]; const where: Prisma.CompanyWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), - ...(search ? { AND: [search] } : {}), + ...(and.length ? { AND: and } : {}), }; const [rows, total] = await this.prisma.$transaction([ @@ -156,11 +172,19 @@ export class CompaniesService { customFields: toJsonCustomFields(dto.customFields), }; await this.assertNoDuplicate(actor.organizationId, data); + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); - const row = await this.prisma.company.create({ + let row = await this.prisma.company.create({ data, include: companyInclude, }); + if (groupIds.length) { + await this.groups.addCompanyGroups(row.id, groupIds); + row = await this.prisma.company.findUniqueOrThrow({ + where: { id: row.id }, + include: companyInclude, + }); + } if (row.importSource === 'manual') { this.game.emit({ organizationId: actor.organizationId, @@ -214,6 +238,10 @@ export class CompaniesService { } await this.assertNoDuplicate(actor.organizationId, data, id); + if (dto.groupIds !== undefined) { + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); + await this.groups.setCompanyGroups(id, groupIds); + } const row = await this.prisma.company.update({ where: { id }, data, @@ -315,6 +343,7 @@ export class CompaniesService { registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null, tags: row.tags ?? [], customFields: asCustomFields(row.customFields), + groups: (row.groupLinks ?? []).map((l) => l.group), peopleCount: row._count.people, owner: row.owner, createdAt: row.createdAt.toISOString(), diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 56870ca..5a2670b 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -25,6 +25,10 @@ import { CompaniesService } from './companies.service'; import { PeopleService } from './people.service'; import { normalizeEmail, normalizePhone } from './normalize'; import { normalizePersonImportSource } from './person-source'; +import { + CustomerGroupsService, + splitGroupNames, +} from '../customer-groups/customer-groups.service'; type ImportChunkOpts = { sessionId?: string; @@ -39,6 +43,16 @@ function parseMood(raw?: string): PersonMood | undefined { return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value); } +/** ستون‌های گروه اکسل → نام گروه‌ها (برچسب‌های وضعیت مشتری گروه حساب نمی‌شوند) */ +function groupNamesFrom(...raws: Array): string[] { + const names = raws.flatMap((raw) => splitGroupNames(raw)); + return [...new Set(names.filter((name) => !parseMood(name)))]; +} + +function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) { + return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? ''); +} + function splitDidarName(firstName: string, lastName: string, title?: string) { const first = firstName.trim(); const last = lastName.trim(); @@ -369,6 +383,7 @@ export class ContactsExcelService { private readonly prisma: PrismaService, private readonly people: PeopleService, private readonly companies: CompaniesService, + private readonly groups: CustomerGroupsService, ) {} async exportPeople(actor: Actor, q?: string): Promise { @@ -378,6 +393,7 @@ export class ContactsExcelService { CONTACT_COLUMNS, items.map((p) => { const extra = (p.customFields ?? {}) as Record; + const groupText = groupNamesText(p.groups, extra.group); return { _type: 'Contact', didarId: extra.didarId ?? p.id, @@ -402,7 +418,7 @@ export class ContactsExcelService { nationalId: extra.nationalId ?? '', registeredAt: formatJalaliDate(p.createdAt), birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), - mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], + mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'], otherPhones: extra.otherPhones ?? '', address: p.addresses[0] ?? extra.address ?? '', website: p.website ?? extra.website ?? '', @@ -410,7 +426,7 @@ export class ContactsExcelService { position2: p.position ?? extra.position ?? '', city2: extra.city ?? '', code2: extra.customerCode ?? '', - group2: extra.group ?? '', + group2: groupText, address2: p.addresses[1] ?? extra.address ?? '', phone2: extra.phone2 ?? p.phone ?? '', postalCode2: extra.postalCode ?? '', @@ -418,7 +434,7 @@ export class ContactsExcelService { homeAddress: p.addresses[2] ?? extra.homeAddress ?? '', birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), instagram: p.instagram ?? extra.instagram ?? '', - group: extra.group ?? '', + group: groupText, colleagueContact: extra.colleagueContact ?? '', previousCompany: extra.previousCompany ?? '', customerType: extra.customerType ?? '', @@ -456,7 +472,7 @@ export class ContactsExcelService { registeredAt: formatJalaliDate( c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt), ), - groupName: c.groupName ?? '', + groupName: groupNamesText(c.groups, c.groupName ?? ''), otherPhones: c.otherPhones ?? '', address: c.address ?? '', website: c.website ?? '', @@ -551,6 +567,8 @@ export class ContactsExcelService { const value = row[key]?.trim(); if (value) customFields[key] = value; } + const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood); + if (groupNames.length) delete customFields.group; const addresses = collectAddresses( row.address, @@ -576,6 +594,7 @@ export class ContactsExcelService { ? normalizePersonImportSource(sourceCol) : 'excel'; const sourceRef = didarId || customerCode || null; + const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames); await this.people.create(actor, { firstName: names.firstName, @@ -596,6 +615,7 @@ export class ContactsExcelService { importSource, sourceRef, registeredAt: registeredAt?.toISOString() ?? null, + groupIds, }); result.created += 1; if (ownerMissing) { @@ -701,8 +721,13 @@ export class ContactsExcelService { }; try { + const groupIds = await this.groups.idsForNames( + actor.organizationId, + groupNamesFrom(row.groupName), + ); const existing = await this.findImportedCompany(actor, didarId, name); if (existing) { + if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds); const merged: Record = {}; for (const [k, v] of Object.entries(payload)) { if (k === 'customFields') continue; @@ -719,6 +744,7 @@ export class ContactsExcelService { await this.companies.create(actor, { ...payload, ownerId: resolveOwnerId(actor, ownerId ?? null), + groupIds, }); result.created += 1; } diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index c374471..0c21610 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -9,6 +9,7 @@ import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { ContactsExcelService } from './contacts-excel.service'; import { BehaviorModule } from '../behavior/behavior.module'; +import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { BehaviorModule } from '../behavior/behavior.module'; MessageAutomationsModule, PublicApiModule, BehaviorModule, + CustomerGroupsModule, ], controllers: [PeopleController, CompaniesController], providers: [PeopleService, CompaniesService, ContactsExcelService], diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index 9e33be2..d03b8fd 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -86,6 +86,12 @@ export class ListQueryDto { @IsString() @MaxLength(12) disc?: string; + + /** فیلتر گروه مشتری */ + @IsOptional() + @IsString() + @MaxLength(40) + groupId?: string; } class CompanyExtraFieldsDto { @@ -153,6 +159,11 @@ class CompanyExtraFieldsDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() registeredAt?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } export class CreateCompanyDto extends CompanyExtraFieldsDto { @@ -370,6 +381,11 @@ export class CreatePersonDto { @IsOptional() @IsString() registeredAt?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } export class UpdatePersonDto { @@ -478,4 +494,9 @@ export class UpdatePersonDto { @ValidateIf((_, v) => v !== '' && v != null) @IsString() ownerId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + groupIds?: string[]; } diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index c8fad31..cf7c878 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -62,6 +62,7 @@ export class PeopleController { city: query.city, hasMobile, disc: query.disc, + groupId: query.groupId, }, ); } @@ -79,6 +80,7 @@ export class PeopleController { city: query.city, hasMobile, disc: query.disc, + groupId: query.groupId, }); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 3967729..0e768e8 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -51,6 +51,10 @@ import { personImportSourceLabel, } from './person-source'; import { BehaviorAccessService } from '../behavior/behavior-access.service'; +import { + CustomerGroupsService, + groupSummarySelect, +} from '../customer-groups/customer-groups.service'; const personInclude = { company: { select: { id: true, name: true, province: true, city: true } }, @@ -58,6 +62,10 @@ const personInclude = { loyaltyTier: { select: { id: true, name: true, minPoints: true, color: true }, }, + groupLinks: { + select: { source: true, group: { select: groupSummarySelect } }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + }, } satisfies Prisma.PersonInclude; type PersonRow = { @@ -102,6 +110,7 @@ type PersonRow = { discLabel: string | null; discReliable: boolean; discAssessedAt: Date | null; + groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>; }; function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null { @@ -134,6 +143,7 @@ export class PeopleService implements OnModuleInit { private readonly sms: SmsGatewayService, private readonly outboundSms: OutboundSmsLogService, private readonly behaviorAccess: BehaviorAccessService, + private readonly groups: CustomerGroupsService, ) {} private personListWhere( @@ -146,6 +156,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, ): Prisma.PersonWhereInput { const sourceFilter = opts?.importSource?.trim() @@ -190,6 +201,8 @@ export class PeopleService implements OnModuleInit { } const disc = discFilter(opts?.disc); if (disc) and.push(disc); + const groupId = opts?.groupId?.trim(); + if (groupId) and.push({ groupLinks: { some: { groupId } } }); return { organizationId: actor.organizationId, ...ownerWhere(actor), @@ -212,6 +225,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, ): Promise> { const seeDisc = await this.behaviorAccess.canView(actor); @@ -249,6 +263,7 @@ export class PeopleService implements OnModuleInit { city?: string; hasMobile?: boolean; disc?: string; + groupId?: string; }, max = FILTERED_IDS_MAX, ): Promise { @@ -396,11 +411,13 @@ export class PeopleService implements OnModuleInit { : {}), }; await this.assertNoDuplicate(actor.organizationId, data); + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); const row = await this.prisma.person.create({ data, include: personInclude, }); + if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds); await this.loyalty.applyWelcomePoints(actor.organizationId, row.id); const createdCtx = { organizationId: actor.organizationId, @@ -489,6 +506,10 @@ export class PeopleService implements OnModuleInit { } await this.assertNoDuplicate(actor.organizationId, data, id); + if (dto.groupIds !== undefined) { + const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds); + await this.groups.setPersonGroups(id, groupIds); + } const row = await this.prisma.person.update({ where: { id }, data, @@ -776,6 +797,10 @@ export class PeopleService implements OnModuleInit { mood: row.mood, tags: row.tags ?? [], customFields: asCustomFields(row.customFields), + groups: (row.groupLinks ?? []).map((l) => ({ + ...l.group, + source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const), + })), loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyTier: row.loyaltyTier, discLabel: includeDisc && row.discReliable ? row.discLabel : null, diff --git a/apps/api/src/customer-groups/customer-groups.controller.ts b/apps/api/src/customer-groups/customer-groups.controller.ts new file mode 100644 index 0000000..fac7798 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { CustomerGroupsService } from './customer-groups.service'; + +class CreateCustomerGroupDto { + @IsString() + @MinLength(1) + @MaxLength(80) + name!: string; + + @IsOptional() + @IsIn([...CUSTOMER_GROUP_COLORS]) + color?: CustomerGroupColor; +} + +class UpdateCustomerGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; + + @IsOptional() + @IsIn([...CUSTOMER_GROUP_COLORS]) + color?: CustomerGroupColor; +} + +@Controller('customer-groups') +@UseGuards(JwtAuthGuard, RolesGuard) +export class CustomerGroupsController { + constructor(private readonly groups: CustomerGroupsService) {} + + @Get() + list(@CurrentUser() user: Actor) { + return this.groups.list(user.organizationId); + } + + @Post() + @Roles('owner', 'admin') + create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) { + return this.groups.create(user.organizationId, dto); + } + + @Patch(':id') + @Roles('owner', 'admin') + update( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateCustomerGroupDto, + ) { + return this.groups.update(user.organizationId, id, dto); + } + + @Delete(':id') + @Roles('owner', 'admin') + async remove(@CurrentUser() user: Actor, @Param('id') id: string) { + await this.groups.remove(user.organizationId, id); + return { ok: true }; + } +} diff --git a/apps/api/src/customer-groups/customer-groups.module.ts b/apps/api/src/customer-groups/customer-groups.module.ts new file mode 100644 index 0000000..ae0b712 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CustomerGroupsController } from './customer-groups.controller'; +import { CustomerGroupsService } from './customer-groups.service'; + +@Module({ + imports: [AuthModule], + controllers: [CustomerGroupsController], + providers: [CustomerGroupsService], + exports: [CustomerGroupsService], +}) +export class CustomerGroupsModule {} diff --git a/apps/api/src/customer-groups/customer-groups.service.ts b/apps/api/src/customer-groups/customer-groups.service.ts new file mode 100644 index 0000000..3140d50 --- /dev/null +++ b/apps/api/src/customer-groups/customer-groups.service.ts @@ -0,0 +1,309 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; +import { CUSTOMER_GROUP_COLORS } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +const NAME_MAX = 80; + +/** نام گروه برای مقایسه: فاصله‌های اضافه و ی/ک عربی یکسان می‌شوند */ +function normalizeGroupName(raw: string): string { + return raw + .replace(/\u064A/g, '\u06CC') + .replace(/\u0643/g, '\u06A9') + .replace(/\s+/g, ' ') + .trim() + .slice(0, NAME_MAX); +} + +/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */ +export function splitGroupNames(raw: string | null | undefined): string[] { + if (!raw) return []; + return [ + ...new Set( + raw + .split(/\s*[،,/]\s*/) + .map(normalizeGroupName) + .filter(Boolean), + ), + ]; +} + +export const groupSummarySelect = { + id: true, + name: true, + color: true, +} satisfies Prisma.CustomerGroupSelect; + +@Injectable() +export class CustomerGroupsService { + constructor(private readonly prisma: PrismaService) {} + + async list(organizationId: string): Promise { + const rows = await this.prisma.customerGroup.findMany({ + where: { organizationId }, + orderBy: [{ position: 'asc' }, { name: 'asc' }], + include: { _count: { select: { people: true, companies: true } } }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + color: r.color, + position: r.position, + fromTahesab: Boolean(r.tahesabGid), + personCount: r._count.people, + companyCount: r._count.companies, + })); + } + + async create( + organizationId: string, + dto: { name: string; color?: CustomerGroupColor }, + ): Promise { + const name = this.cleanName(dto.name); + const max = await this.prisma.customerGroup.aggregate({ + where: { organizationId }, + _max: { position: true }, + }); + try { + await this.prisma.customerGroup.create({ + data: { + organizationId, + name, + color: this.cleanColor(dto.color), + position: (max._max.position ?? -1) + 1, + }, + }); + } catch (e) { + throw this.mapError(e); + } + return (await this.list(organizationId)).find((g) => g.name === name)!; + } + + async update( + organizationId: string, + id: string, + dto: { name?: string; color?: CustomerGroupColor }, + ): Promise { + await this.require(organizationId, id); + try { + await this.prisma.customerGroup.update({ + where: { id }, + data: { + ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), + ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), + }, + }); + } catch (e) { + throw this.mapError(e); + } + return (await this.list(organizationId)).find((g) => g.id === id)!; + } + + /** مشتری‌ها حذف نمی‌شوند؛ فقط این گروه از رویشان برداشته می‌شود */ + async remove(organizationId: string, id: string): Promise { + await this.require(organizationId, id); + await this.prisma.customerGroup.delete({ where: { id } }); + } + + /** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */ + async validIds(organizationId: string, ids: string[] | undefined): Promise { + const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))]; + if (!unique.length) return []; + const rows = await this.prisma.customerGroup.findMany({ + where: { organizationId, id: { in: unique } }, + select: { id: true }, + }); + if (rows.length !== unique.length) { + throw new BadRequestException('یکی از گروه‌های انتخاب‌شده معتبر نیست'); + } + return rows.map((r) => r.id); + } + + /** گروه‌ها با نام (برای اکسل)؛ نبود → ساخته می‌شود */ + async idsForNames(organizationId: string, names: string[]): Promise { + const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))]; + if (!clean.length) return []; + const ids: string[] = []; + for (const name of clean) { + ids.push((await this.findOrCreateByName(organizationId, name)).id); + } + return ids; + } + + private async findOrCreateByName(organizationId: string, name: string) { + const existing = await this.prisma.customerGroup.findFirst({ + where: { organizationId, name: { equals: name, mode: 'insensitive' } }, + select: { id: true, tahesabGid: true }, + }); + if (existing) return existing; + const max = await this.prisma.customerGroup.aggregate({ + where: { organizationId }, + _max: { position: true }, + }); + try { + return await this.prisma.customerGroup.create({ + data: { organizationId, name, position: (max._max.position ?? -1) + 1 }, + select: { id: true, tahesabGid: true }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return this.prisma.customerGroup.findFirstOrThrow({ + where: { organizationId, name }, + select: { id: true, tahesabGid: true }, + }); + } + throw e; + } + } + + /** + * گروه متناظر یک گروه ته‌حساب: اول با GID روی همان اتصال، بعد با نام. + * با تغییر نام در ته‌حساب گروه تکراری ساخته نمی‌شود. + */ + async ensureTahesabGroup( + organizationId: string, + connectionId: string, + gid: string | null, + rawName: string, + ): Promise { + const name = normalizeGroupName(rawName); + const cleanGid = gid?.trim() || null; + if (!name && !cleanGid) return null; + if (cleanGid) { + const byGid = await this.prisma.customerGroup.findFirst({ + where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid }, + select: { id: true, name: true }, + }); + if (byGid) { + if (name && byGid.name !== name) { + await this.prisma.customerGroup + .update({ where: { id: byGid.id }, data: { name } }) + .catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی می‌ماند + } + return byGid.id; + } + } + if (!name) return null; + const group = await this.findOrCreateByName(organizationId, name); + if (cleanGid && !group.tahesabGid) { + await this.prisma.customerGroup.update({ + where: { id: group.id }, + data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid }, + }); + } + return group.id; + } + + /** گروه‌های دستی شخص (جایگزین کامل). منبع لینک‌هایی که می‌مانند حفظ می‌شود. */ + async setPersonGroups(personId: string, groupIds: string[]): Promise { + await this.prisma.$transaction([ + this.prisma.personGroupLink.deleteMany({ + where: { personId, groupId: { notIn: groupIds } }, + }), + this.prisma.personGroupLink.createMany({ + data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), + skipDuplicates: true, + }), + ]); + } + + async setCompanyGroups(companyId: string, groupIds: string[]): Promise { + await this.prisma.$transaction([ + this.prisma.companyGroupLink.deleteMany({ + where: { companyId, groupId: { notIn: groupIds } }, + }), + this.prisma.companyGroupLink.createMany({ + data: groupIds.map((groupId) => ({ companyId, groupId })), + skipDuplicates: true, + }), + ]); + } + + /** فقط اضافه می‌کند (ایمپورت اکسل) */ + async addPersonGroups(personId: string, groupIds: string[]): Promise { + if (!groupIds.length) return; + await this.prisma.personGroupLink.createMany({ + data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })), + skipDuplicates: true, + }); + } + + async addCompanyGroups(companyId: string, groupIds: string[]): Promise { + if (!groupIds.length) return; + await this.prisma.companyGroupLink.createMany({ + data: groupIds.map((groupId) => ({ companyId, groupId })), + skipDuplicates: true, + }); + } + + /** + * گروهِ آمده از ته‌حساب: گروه ته‌حسابی قبلی برداشته و این یکی گذاشته می‌شود. + * گروه‌های دستی دست نمی‌خورند. اگر همین گروه از قبل دستی بوده، دستی می‌ماند. + * @returns آیا چیزی عوض شد + */ + async setPersonTahesabGroup(personId: string, groupId: string | null): Promise { + const current = await this.prisma.personGroupLink.findMany({ + where: { personId }, + select: { groupId: true, source: true }, + }); + const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId); + const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true; + if (!staleTahesab.length && alreadyLinked) return false; + await this.prisma.$transaction([ + ...(staleTahesab.length + ? [ + this.prisma.personGroupLink.deleteMany({ + where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } }, + }), + ] + : []), + ...(groupId && !alreadyLinked + ? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })] + : []), + ]); + return true; + } + + /** گروهی که موقع «ثبت در ته‌حساب» فرستاده می‌شود: اولین گروه (به ترتیب گروه‌ها) */ + async firstGroupName(personId: string): Promise { + const link = await this.prisma.personGroupLink.findFirst({ + where: { personId }, + orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }], + select: { group: { select: { name: true } } }, + }); + return link?.group.name ?? null; + } + + private async require(organizationId: string, id: string) { + const row = await this.prisma.customerGroup.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException('گروه پیدا نشد'); + } + + private cleanName(raw: string): string { + const name = normalizeGroupName(raw ?? ''); + if (!name) throw new BadRequestException('نام گروه لازم است'); + return name; + } + + private cleanColor(raw?: string): CustomerGroupColor { + return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '') + ? (raw as CustomerGroupColor) + : 'teal'; + } + + private mapError(e: unknown) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return new ConflictException('گروهی با این نام وجود دارد'); + } + return e; + } +} diff --git a/apps/api/src/integrations/tahesab/dto.ts b/apps/api/src/integrations/tahesab/dto.ts index 9481a75..63e8206 100644 --- a/apps/api/src/integrations/tahesab/dto.ts +++ b/apps/api/src/integrations/tahesab/dto.ts @@ -1,4 +1,5 @@ import { + ArrayMaxSize, IsArray, IsBoolean, IsIn, @@ -176,6 +177,35 @@ export class ImportBulkFromTahesabDto { pageSize?: number; } +export class SyncGroupsPageDto { + @IsString() + connectionId!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(100) + @Max(2000) + pageSize?: number; +} + +export class ExportPeopleToTahesabDto { + @IsString() + connectionId!: string; + + /** هر درخواست چند نفر؛ هر نفر چند تماس با ته‌حساب دارد */ + @IsArray() + @ArrayMaxSize(25) + @IsString({ each: true }) + personIds!: string[]; +} + export class SearchTahesabDto { @IsString() connectionId!: string; diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index efcc169..703cbc7 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -12,13 +12,16 @@ import { carryPreviousSource } from '../../contacts/person-source'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, + ExportPeopleToTahesabDto, ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, SearchTahesabDto, + SyncGroupsPageDto, } from './dto'; -import { parseBirthDate } from '../../common/excel'; +import { formatJalaliDate, parseBirthDate } from '../../common/excel'; +import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ @@ -71,6 +74,23 @@ function pickBirthDate(row: Record): Date | null { return parseBirthDate(String(raw)); } +function firstAddress(raw: unknown): string | undefined { + if (!Array.isArray(raw)) return undefined; + const first = raw.find((a) => typeof a === 'string' && a.trim()); + return typeof first === 'string' ? first.trim() : undefined; +} + +/** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */ +function pickGroup(row: Record): { gid: string | null; name: string } | null { + const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId; + const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup; + if (rawGid == null && rawName == null) return null; + const gid = rawGid == null ? null : String(rawGid).trim() || null; + const name = rawName == null ? '' : String(rawName).trim(); + if (!gid && !name) return null; + return { gid, name }; +} + @Injectable() export class TahesabPersonLinkService { private readonly logger = new Logger(TahesabPersonLinkService.name); @@ -78,8 +98,37 @@ export class TahesabPersonLinkService { constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, + private readonly groups: CustomerGroupsService, ) {} + /** + * گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند. + * @returns آیا گروه شخص عوض شد + */ + private async applyTahesabGroup( + organizationId: string, + connectionId: string, + personId: string, + row: Record | undefined, + ): Promise { + const group = row ? pickGroup(row) : null; + if (!group) return false; + try { + const groupId = await this.groups.ensureTahesabGroup( + organizationId, + connectionId, + group.gid, + group.name, + ); + return await this.groups.setPersonTahesabGroup(personId, groupId); + } catch (err) { + this.logger.warn( + `گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`, + ); + return false; + } + } + private async getPerson(organizationId: string, personId: string) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, @@ -305,10 +354,17 @@ export class TahesabPersonLinkService { person.mobile ?? person.phone ?? ''; + const extra = (person.customFields ?? {}) as Record; + const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : ''; + const groupName = + dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM'; const args = SanadBuilders.newMoshtari({ name: `${person.firstName} ${person.lastName}`.trim(), - groupName: dto.groupName ?? 'CRM', + groupName, tel, + address: firstAddress(person.addresses), + codeMeli: nationalId || undefined, + tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined, jensFelez: dto.jensFelez ?? 0, }); const res = await api.doNewMoshtari(args); @@ -628,6 +684,7 @@ export class TahesabPersonLinkService { connection: { select: { label: true, dbName: true, isDefault: true } }, }, }); + await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); await this.prisma.accountingSyncJob.create({ data: { @@ -645,6 +702,7 @@ export class TahesabPersonLinkService { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); + const rowsByCode = new Map>(); if (dto.listAll) { const { api } = await this.connections.getApi( @@ -656,10 +714,11 @@ export class TahesabPersonLinkService { dto.page ?? 1, dto.pageSize, ); - const listedCodes = listed.rows - .map((row) => pickCode(row)) - .filter((c): c is number => c != null); - codes = [...new Set([...codes, ...listedCodes])]; + for (const row of listed.rows) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + codes = [...new Set([...codes, ...rowsByCode.keys()])]; } else if (dto.fromCode != null && dto.toCode != null) { if (dto.toCode < dto.fromCode) { throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); @@ -674,10 +733,11 @@ export class TahesabPersonLinkService { dto.connectionId, ); const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode); - const listed = asMoshtariList(raw) - .map((row) => pickCode(row)) - .filter((c): c is number => c != null); - codes = [...new Set([...codes, ...listed])]; + for (const row of asMoshtariList(raw)) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + codes = [...new Set([...codes, ...rowsByCode.keys()])]; } if (!codes.length) { @@ -708,11 +768,13 @@ export class TahesabPersonLinkService { const pending = codes.filter((c) => !linkedByCode.has(c)); let bulkApi: TahesabApi | undefined; - const rowsByCode = new Map>(); + const missingRows = pending.filter((c) => !rowsByCode.has(c)); if (pending.length) { bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; - const minCode = Math.min(...pending); - const maxCode = Math.max(...pending); + } + if (bulkApi && missingRows.length) { + const minCode = Math.min(...missingRows); + const maxCode = Math.max(...missingRows); if (maxCode - minCode <= 2000) { try { const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode); @@ -737,6 +799,12 @@ export class TahesabPersonLinkService { data: { birthDate }, }); } + await this.applyTahesabGroup( + organizationId, + dto.connectionId, + linkedPersonId, + rowsByCode.get(moshtariCode), + ); items.push({ moshtariCode, ok: true, @@ -776,6 +844,146 @@ export class TahesabPersonLinkService { return { imported, skipped, failed, items }; } + /** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */ + async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) { + const { api } = await this.connections.getApi(organizationId, dto.connectionId); + const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize); + const rowsByCode = new Map>(); + for (const row of listed.rows) { + const code = pickCode(row); + if (code != null) rowsByCode.set(code, row); + } + const links = rowsByCode.size + ? await this.prisma.personAccountingLink.findMany({ + where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } }, + select: { moshtariCode: true, personId: true }, + }) + : []; + let updated = 0; + for (const link of links) { + const changed = await this.applyTahesabGroup( + organizationId, + dto.connectionId, + link.personId, + rowsByCode.get(link.moshtariCode), + ); + if (changed) updated += 1; + } + return { + page: listed.page, + fromCode: listed.fromCode, + toCode: listed.toCode, + rows: listed.rows.length, + linked: links.length, + updated, + }; + } + + /** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */ + async unlinkedPeople(organizationId: string, connectionId: string) { + await this.connections.getOrThrow(organizationId, connectionId); + const rows = await this.prisma.person.findMany({ + where: { organizationId, accountingLinks: { none: { connectionId } } }, + select: { id: true }, + orderBy: { createdAt: 'asc' }, + }); + return { ids: rows.map((r) => r.id), total: rows.length }; + } + + /** + * ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛ + * تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری. + */ + async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) { + await this.connections.getOrThrow(organizationId, dto.connectionId); + const ids = [...new Set(dto.personIds)]; + const people = await this.prisma.person.findMany({ + where: { organizationId, id: { in: ids } }, + select: { + id: true, + firstName: true, + lastName: true, + accountingLinks: { + where: { connectionId: dto.connectionId }, + select: { moshtariCode: true }, + }, + }, + }); + const byId = new Map(people.map((p) => [p.id, p])); + + const items: Array<{ + personId: string; + name: string; + status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error'; + moshtariCode?: number; + error?: string; + }> = []; + + for (const personId of ids) { + const person = byId.get(personId); + if (!person) { + items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' }); + continue; + } + const name = `${person.firstName} ${person.lastName}`.trim(); + const existing = person.accountingLinks[0]; + if (existing) { + items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode }); + continue; + } + try { + const matched = await this.match(organizationId, personId, { + connectionId: dto.connectionId, + autoLinkIfUnique: true, + }); + if (matched.status === 'linked' && matched.link) { + items.push({ + personId, + name, + status: 'matched', + moshtariCode: matched.link.moshtariCode, + }); + } else if (matched.status === 'none') { + const link = await this.createInTahesab(organizationId, personId, { + connectionId: dto.connectionId, + }); + items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode }); + } else { + items.push({ personId, name, status: 'ambiguous' }); + } + } catch (e) { + items.push({ + personId, + name, + status: 'error', + error: e instanceof Error ? e.message : 'ثبت ناموفق بود', + }); + } + } + + const count = (s: (typeof items)[number]['status']) => + items.filter((i) => i.status === s).length; + const summary = { + matched: count('matched'), + created: count('created'), + ambiguous: count('ambiguous'), + skipped: count('skipped'), + failed: count('error'), + }; + await this.prisma.accountingSyncJob.create({ + data: { + organizationId, + connectionId: dto.connectionId, + kind: 'export', + status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success', + request: { count: ids.length } as Prisma.InputJsonValue, + result: summary as Prisma.InputJsonValue, + }, + }); + + return { ...summary, items }; + } + /** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */ async fillMissingBirthDates() { const connections = await this.prisma.accountingConnection.findMany({ diff --git a/apps/api/src/integrations/tahesab/tahesab.controller.ts b/apps/api/src/integrations/tahesab/tahesab.controller.ts index 5d09d6a..4d76861 100644 --- a/apps/api/src/integrations/tahesab/tahesab.controller.ts +++ b/apps/api/src/integrations/tahesab/tahesab.controller.ts @@ -21,6 +21,7 @@ import { TahesabDocumentsService } from './documents.service'; import { CreateInTahesabDto, CreateTahesabConnectionDto, + ExportPeopleToTahesabDto, ImportBulkFromTahesabDto, ImportBulkProductsFromTahesabDto, ImportFromTahesabDto, @@ -30,6 +31,7 @@ import { PushSanadDto, SearchTahesabDto, SearchTahesabProductsDto, + SyncGroupsPageDto, UpdateTahesabConnectionDto, } from './dto'; @@ -216,6 +218,33 @@ export class TahesabController { return this.links.importBulk(user.organizationId, dto); } + @Post('sync/groups') + @Roles('owner', 'admin') + syncGroups( + @CurrentUser() user: { organizationId: string }, + @Body() dto: SyncGroupsPageDto, + ) { + return this.links.syncGroupsPage(user.organizationId, dto); + } + + @Get('sync/unlinked-people') + @Roles('owner', 'admin') + unlinkedPeople( + @CurrentUser() user: { organizationId: string }, + @Query('connectionId') connectionId: string, + ) { + return this.links.unlinkedPeople(user.organizationId, connectionId); + } + + @Post('sync/export-people') + @Roles('owner', 'admin') + exportPeople( + @CurrentUser() user: { organizationId: string }, + @Body() dto: ExportPeopleToTahesabDto, + ) { + return this.links.exportPeople(user.organizationId, dto); + } + @Post('sync/products/search') @Roles('owner', 'admin', 'sales') searchProducts( diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index b1dbee8..54c714a 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -11,9 +11,10 @@ import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; import { TahesabController } from './tahesab.controller'; +import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, CustomerGroupsModule], controllers: [TahesabController], providers: [ TahesabCryptoService, diff --git a/apps/web/src/components/CustomerGroupChips.vue b/apps/web/src/components/CustomerGroupChips.vue new file mode 100644 index 0000000..5b66dbe --- /dev/null +++ b/apps/web/src/components/CustomerGroupChips.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/web/src/components/CustomerGroupPicker.vue b/apps/web/src/components/CustomerGroupPicker.vue new file mode 100644 index 0000000..43a4afe --- /dev/null +++ b/apps/web/src/components/CustomerGroupPicker.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/web/src/components/CustomerGroupsManager.vue b/apps/web/src/components/CustomerGroupsManager.vue new file mode 100644 index 0000000..bdeb5d5 --- /dev/null +++ b/apps/web/src/components/CustomerGroupsManager.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/apps/web/src/lib/customer-groups.ts b/apps/web/src/lib/customer-groups.ts new file mode 100644 index 0000000..f6b4d4e --- /dev/null +++ b/apps/web/src/lib/customer-groups.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue'; +import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; +import { api } from '@/lib/api'; + +export const CUSTOMER_GROUP_COLOR_LABELS: Record = { + teal: 'سبزآبی', + sky: 'آبی', + violet: 'بنفش', + rose: 'قرمز', + amber: 'نارنجی', + pink: 'صورتی', + slate: 'خاکستری', +}; + +/** رنگ متن/پس‌زمینهٔ چیپ گروه (پالت داده، نه رنگ‌های قالب) */ +export const CUSTOMER_GROUP_SWATCH: Record = { + teal: { fg: '#0f766e', bg: '#ccfbf1' }, + sky: { fg: '#0369a1', bg: '#e0f2fe' }, + violet: { fg: '#6d28d9', bg: '#ede9fe' }, + rose: { fg: '#be123c', bg: '#ffe4e6' }, + amber: { fg: '#b45309', bg: '#fef3c7' }, + pink: { fg: '#be185d', bg: '#fce7f3' }, + slate: { fg: '#334155', bg: '#e2e8f0' }, +}; + +export function groupSwatch(color: string | null | undefined) { + return CUSTOMER_GROUP_SWATCH[(color ?? 'teal') as CustomerGroupColor] ?? CUSTOMER_GROUP_SWATCH.teal; +} + +const groups = ref([]); +const loaded = ref(false); +let pending: Promise | null = null; + +/** فهرست گروه‌های مشتری محیط (مشترک بین صفحه‌ها) */ +export function useCustomerGroups() { + async function load(force = false): Promise { + if (loaded.value && !force) return groups.value; + if (pending && !force) return pending; + pending = api + .get('/customer-groups') + .then(({ data }) => { + groups.value = data; + loaded.value = true; + return data; + }) + .finally(() => { + pending = null; + }); + return pending; + } + + return { groups, loaded, load }; +} diff --git a/apps/web/src/views/CompanyFormView.vue b/apps/web/src/views/CompanyFormView.vue index 2812d48..e5833c2 100644 --- a/apps/web/src/views/CompanyFormView.vue +++ b/apps/web/src/views/CompanyFormView.vue @@ -8,6 +8,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import EntityTimeline from '@/components/EntityTimeline.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import CustomerGroupPicker from '@/components/CustomerGroupPicker.vue'; import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali'; import { UiButton, @@ -59,6 +60,7 @@ const city = ref(''); const postalCode = ref(''); const address = ref(''); const groupName = ref(''); +const groupIds = ref([]); const registeredAt = ref(''); const notes = ref(''); const tagsRaw = ref(''); @@ -94,6 +96,7 @@ function fill(c: CompanyDto) { postalCode.value = c.postalCode ?? ''; address.value = c.address ?? ''; groupName.value = c.groupName ?? ''; + groupIds.value = (c.groups ?? []).map((g) => g.id); registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : ''; notes.value = c.notes ?? ''; tagsRaw.value = (c.tags ?? []).join('، '); @@ -134,6 +137,7 @@ function payload(): CreateCompanyRequest { postalCode: postalCode.value || null, address: address.value || undefined, groupName: groupName.value || null, + groupIds: groupIds.value, registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null, notes: notes.value || undefined, tags: parseTags(tagsRaw.value), @@ -254,8 +258,8 @@ onMounted(load); - - + + diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index cf09817..903091f 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -40,6 +40,10 @@ import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { canUseBehavior } from '@/lib/behavior-access'; import DiscBadge from '@/components/behavior/DiscBadge.vue'; import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize'; +import CustomerGroupChips from '@/components/CustomerGroupChips.vue'; +import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue'; +import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups'; +import { canManageSettings } from '@/lib/roles'; type Tab = 'people' | 'companies'; type SortKey = 'name' | 'newest'; @@ -73,6 +77,19 @@ const sourceFilter = computed(() => { ? (value as PersonImportSource) : 'all'; }); +const groupFilter = computed(() => + typeof route.query.group === 'string' ? route.query.group : '', +); +const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups(); +const canManageGroups = computed(() => canManageSettings(auth.user?.role)); +const groupsManagerOpen = ref(false); +const visibleGroups = computed(() => + customerGroups.value.filter( + (g) => + g.id === groupFilter.value || + (tab.value === 'people' ? g.personCount : g.companyCount) > 0, + ), +); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const loading = ref(false); const busy = ref(false); @@ -145,6 +162,7 @@ const peopleSavedFilters = computed(() => ({ mood: moodFilter.value === 'all' ? undefined : moodFilter.value, source: sourceFilter.value === 'all' ? undefined : sourceFilter.value, disc: discFilter.value || undefined, + group: groupFilter.value || undefined, })); function applyPeopleSavedFilters(filters: Record) { @@ -166,6 +184,7 @@ function applyPeopleSavedFilters(filters: Record) { sort: nextSort === 'name' ? undefined : nextSort, mood: nextMood === 'all' ? undefined : nextMood, source: nextSource === 'all' ? undefined : nextSource, + group: typeof filters.group === 'string' && filters.group ? filters.group : undefined, page: undefined, tab: undefined, }); @@ -221,6 +240,7 @@ async function load() { loading.value = true; error.value = ''; closePopups(); + void loadCustomerGroups(true).catch(() => undefined); try { if (tab.value === 'people') { const [peopleRes, statsRes] = await Promise.all([ @@ -234,6 +254,7 @@ async function load() { importSource: sourceFilter.value === 'all' ? undefined : sourceFilter.value, disc: discFilter.value || undefined, + groupId: groupFilter.value || undefined, }, }), api.get('/people/source-stats').catch(() => ({ @@ -246,7 +267,12 @@ async function load() { selectedIds.value = selectedIds.value.filter((id) => visible.has(id)); } else { const { data } = await api.get>('/companies', { - params: { q: q.value || undefined, page: page.value, limit: pageSize.value }, + params: { + q: q.value || undefined, + page: page.value, + limit: pageSize.value, + groupId: groupFilter.value || undefined, + }, }); companies.value = data; } @@ -295,6 +321,14 @@ function setDisc(next: string) { patchQuery({ disc: next || undefined, page: undefined }); } +function setGroup(next: string) { + patchQuery({ group: next || undefined, page: undefined }); +} + +async function onGroupsChanged() { + await load(); +} + function setSource(next: PersonImportSource | 'all') { patchQuery({ source: next === 'all' ? undefined : next, @@ -409,7 +443,7 @@ function onImportSettled() { } watch( - () => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source], + () => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source, route.query.group], () => { q.value = typeof route.query.q === 'string' ? route.query.q : ''; void load(); @@ -556,6 +590,33 @@ onMounted(load);
+
+ + + +
+
+ {{ sourceLabel(p.importSource || 'manual') }}
@@ -767,6 +833,9 @@ onMounted(load); {{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}

+

+ +

{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }} {{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }} @@ -806,7 +875,10 @@ onMounted(load); {{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }} {{ c.code ? toFaDigits(c.code) : '—' }} {{ sourceLabel(c.importSource || 'manual') }} - {{ c.groupName || '—' }} + + + {{ c.groupName || '—' }} + {{ c.province || '—' }} {{ c.city || '—' }} {{ c.phone || '—' }} @@ -856,6 +928,13 @@ onMounted(load); @cancel="contactModal = null" /> + + @@ -997,6 +1076,10 @@ h1 { font-style: normal; font-weight: 700; } +.manage-chip { + border-style: dashed; + color: #64748b; +} .tiny { font-size: 0.75rem; margin-top: 0.15rem; diff --git a/apps/web/src/views/PersonFormView.vue b/apps/web/src/views/PersonFormView.vue index 0c2df10..4466074 100644 --- a/apps/web/src/views/PersonFormView.vue +++ b/apps/web/src/views/PersonFormView.vue @@ -38,6 +38,7 @@ import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@me import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { formatLocalDateInput, toFaDigits } from '@/lib/jalali'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import { useCustomerGroups } from '@/lib/customer-groups'; import { filterPhoneContacts, isContactPickerSupported, @@ -97,7 +98,13 @@ const notes = ref(''); const tagsRaw = ref(''); const companyId = ref(''); const mood = ref('newcomer'); +const groupIds = ref([]); const customFields = ref>({}); +const { groups: customerGroups, loaded: groupsLoaded, load: loadCustomerGroups } = + useCustomerGroups(); +const groupOptions = computed(() => + customerGroups.value.map((g) => ({ value: g.id, label: g.name })), +); const loyaltyPoints = ref(0); const loyaltyTierName = ref(''); @@ -257,6 +264,7 @@ function fill(p: PersonDto) { tagsRaw.value = (p.tags ?? []).join('، '); companyId.value = p.company?.id ?? ''; mood.value = p.mood || 'newcomer'; + groupIds.value = (p.groups ?? []).map((g) => g.id); customFields.value = { ...(p.customFields ?? {}) }; loyaltyPoints.value = p.loyaltyPoints ?? 0; loyaltyTierName.value = p.loyaltyTier?.name ?? ''; @@ -277,6 +285,7 @@ function removeAddress(index: number) { async function load() { loading.value = true; error.value = ''; + void loadCustomerGroups().catch(() => undefined); try { const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 }, @@ -334,6 +343,7 @@ function payload(): CreatePersonRequest { tags: parseTags(tagsRaw.value), companyId: companyId.value || null, mood: mood.value, + groupIds: groupIds.value, customFields: customFields.value, }; } @@ -558,6 +568,20 @@ onMounted(load); /> + + + + + >(() => { { key: 'tasks', label: 'کارهای مرتبط' }, { key: 'deals', label: 'معاملات' }, { key: 'cards', label: 'کارت‌ها' }, - { key: 'assets', label: 'دارایی' }, + { key: 'assets', label: 'ته‌حساب و دارایی' }, { key: 'info', label: 'اطلاعات' }, ]; if (canBehavior.value) { @@ -759,6 +760,10 @@ watch(() => route.query.tab, readTab, { immediate: true });

+
+
گروه‌ها
+
+
شرکت
{{ person.company?.name || '—' }}
@@ -809,6 +814,7 @@ watch(() => route.query.tab, readTab, { immediate: true });

{{ subtitle }}

+
diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index e9e07f7..f8cb47f 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -496,6 +496,128 @@ async function importBulk() { } } +/** همهٔ بازه‌های کد خوانده می‌شود و گروه ته‌حساب روی مشتری‌های لینک‌شده نشانده می‌شود */ +async function syncGroups() { + error.value = ''; + ok.value = ''; + if (!sync.connectionId) { + error.value = 'ابتدا اتصال را انتخاب کنید.'; + return; + } + syncBusy.value = true; + importOverlay.open = true; + importOverlay.progress = null; + const totals = { rows: 0, linked: 0, updated: 0 }; + try { + let page = 1; + let emptyStreak = 0; + while (emptyStreak < LIST_ALL_EMPTY_STOP) { + importOverlay.message = `در حال همگام‌سازی گروه‌ها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`; + const { data } = await api.post<{ rows: number; linked: number; updated: number }>( + '/integrations/tahesab/sync/groups', + { connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE }, + { timeout: 120_000 }, + ); + totals.rows += data.rows; + totals.linked += data.linked; + totals.updated += data.updated; + emptyStreak = data.rows ? 0 : emptyStreak + 1; + page += 1; + } + ok.value = `گروه‌ها همگام شد: ${toFaDigits(totals.linked)} مشتری لینک‌شده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر به‌روز شد.`; + } catch (e) { + error.value = apiErr(e, 'همگام‌سازی گروه‌ها ناموفق بود'); + } finally { + importOverlay.open = false; + syncBusy.value = false; + } +} + +type ExportItem = { + personId: string; + name: string; + status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error'; + moshtariCode?: number; + error?: string; +}; +const EXPORT_CHUNK = 10; +const exportIssues = ref([]); + +/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */ +async function exportUnlinked() { + error.value = ''; + ok.value = ''; + exportIssues.value = []; + if (!sync.connectionId) { + error.value = 'ابتدا اتصال را انتخاب کنید.'; + return; + } + syncBusy.value = true; + try { + const { data: unlinked } = await api.get<{ ids: string[]; total: number }>( + '/integrations/tahesab/sync/unlinked-people', + { params: { connectionId: sync.connectionId } }, + ); + if (!unlinked.total) { + ok.value = 'همهٔ اشخاص به ته‌حساب لینک هستند.'; + return; + } + if ( + !confirm( + `${toFaDigits(unlinked.total)} شخص به ته‌حساب لینک نیستند.\n` + + 'برای هر نفر اول با موبایل/نام در ته‌حساب جستجو می‌شود: اگر دقیقاً یک نفر پیدا شد فقط لینک می‌شود، ' + + 'اگر چند نفر پیدا شد رد می‌شود تا دستی بررسی کنید، و اگر پیدا نشد در ته‌حساب ساخته می‌شود. ادامه؟', + ) + ) { + return; + } + importOverlay.open = true; + importOverlay.progress = 0; + const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 }; + const ids = unlinked.ids; + for (let i = 0; i < ids.length; i += EXPORT_CHUNK) { + const chunk = ids.slice(i, i + EXPORT_CHUNK); + importOverlay.message = `در حال ثبت در ته‌حساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`; + try { + const { data } = await api.post( + '/integrations/tahesab/sync/export-people', + { connectionId: sync.connectionId, personIds: chunk }, + { timeout: 180_000 }, + ); + totals.matched += data.matched; + totals.created += data.created; + totals.ambiguous += data.ambiguous; + totals.skipped += data.skipped; + totals.failed += data.failed; + exportIssues.value.push( + ...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'), + ); + } catch (e) { + totals.failed += chunk.length; + exportIssues.value.push( + ...chunk.map((personId) => ({ + personId, + name: '', + status: 'error' as const, + error: apiErr(e, 'خطا در ارسال'), + })), + ); + } + importOverlay.progress = ((i + chunk.length) / ids.length) * 100; + } + ok.value = + `ثبت در ته‌حساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` + + `ساخته‌شده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` + + `ناموفق ${toFaDigits(totals.failed)}`; + } catch (e) { + error.value = apiErr(e, 'ثبت گروهی در ته‌حساب ناموفق بود'); + } finally { + importOverlay.open = false; + importOverlay.progress = null; + syncBusy.value = false; + } +} + async function searchProducts() { error.value = ''; ok.value = ''; @@ -807,6 +929,43 @@ onMounted(load);
+
+

۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب

+

+ «همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض + می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند. + «ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان + می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود. +

+
+ + همگام‌سازی گروه‌ها از ته‌حساب + + + ثبت مشتری‌های لینک‌نشده در ته‌حساب + +
+
+

+ این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی» + دستی لینک یا ثبت کنید: +

+
    +
  • + + {{ it.name || 'بدون نام' }} + + — + {{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }} +
  • +
+
+
+

نتایج جستجو ({{ syncVisibleResults.length }}) @@ -1176,6 +1335,14 @@ onMounted(load); flex-wrap: wrap; } +.export-issues ul { + margin: var(--sp-2) 0 0; + padding-inline-start: 1.2rem; + font-size: var(--fs-xs); + max-height: 14rem; + overflow-y: auto; +} + .sync-table-pagination { margin-top: var(--sp-3); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 16f22d3..71f65cd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -774,6 +774,48 @@ export interface HealthResponse { timestamp: string; } +/* ───────── گروه مشتری (اشخاص و شرکت‌ها) ───────── */ + +export const CUSTOMER_GROUP_COLORS = [ + "teal", + "sky", + "violet", + "rose", + "amber", + "pink", + "slate", +] as const; +export type CustomerGroupColor = (typeof CUSTOMER_GROUP_COLORS)[number]; + +export interface CustomerGroupSummaryDto { + id: string; + name: string; + color: CustomerGroupColor | string; + /** manual | tahesab — فقط برای اشخاص */ + source?: "manual" | "tahesab"; +} + +export interface CustomerGroupDto { + id: string; + name: string; + color: CustomerGroupColor | string; + position: number; + /** گروه متناظر در ته‌حساب دارد */ + fromTahesab: boolean; + personCount: number; + companyCount: number; +} + +export interface CreateCustomerGroupRequest { + name: string; + color?: CustomerGroupColor; +} + +export interface UpdateCustomerGroupRequest { + name?: string; + color?: CustomerGroupColor; +} + export interface CompanySummaryDto { id: string; name: string; @@ -807,6 +849,7 @@ export interface CompanyDto { registeredAt: string | null; tags: string[]; customFields: Record; + groups: CustomerGroupSummaryDto[]; peopleCount: number; owner: UserSummaryDto | null; createdAt: string; @@ -837,6 +880,8 @@ export interface CreateCompanyRequest { tags?: string[]; customFields?: Record; ownerId?: string | null; + /** همهٔ گروه‌های شرکت (جایگزین کامل) */ + groupIds?: string[]; } export type UpdateCompanyRequest = Partial; @@ -914,6 +959,7 @@ export interface PersonDto { mood: PersonMood; tags: string[]; customFields: Record; + groups: CustomerGroupSummaryDto[]; loyaltyPoints: number; loyaltyTier: LoyaltyTierSummaryDto | null; /** تیپ غالب یا ترکیبی تأییدشده؛ بدون مجوز رفتار خالی برمی‌گردد */ @@ -950,6 +996,8 @@ export interface CreatePersonRequest { ownerId?: string | null; /** فقط برای ایمپورت اکسل — تاریخ ثبت مبدأ */ registeredAt?: string | null; + /** همهٔ گروه‌های شخص (جایگزین کامل؛ منبع گروه‌های ته‌حسابیِ باقی‌مانده حفظ می‌شود) */ + groupIds?: string[]; } export type UpdatePersonRequest = Partial; From 50d7cad66ed8e1a4911d6ba2e2fd8c39d25d5640 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 18:07:21 +0330 Subject: [PATCH 04/65] feat: multi-select contacts filter panel for groups sources and mood --- apps/api/src/common/search.ts | 13 + apps/api/src/contacts/companies.service.ts | 6 +- apps/api/src/contacts/dto.ts | 14 +- apps/api/src/contacts/people.service.ts | 25 +- .../src/components/ContactsFilterPanel.vue | 364 ++++++++++++++++++ apps/web/src/views/ContactsView.vue | 295 ++++++++------ 6 files changed, 573 insertions(+), 144 deletions(-) create mode 100644 apps/web/src/components/ContactsFilterPanel.vue diff --git a/apps/api/src/common/search.ts b/apps/api/src/common/search.ts index 918d58e..39404e3 100644 --- a/apps/api/src/common/search.ts +++ b/apps/api/src/common/search.ts @@ -79,6 +79,19 @@ export function buildSearchWhere( return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W; } +/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */ +export function splitCsv(raw: string | null | undefined, max = 50): string[] { + if (!raw) return []; + return [ + ...new Set( + String(raw) + .split(',') + .map((v) => v.trim()) + .filter(Boolean), + ), + ].slice(0, max); +} + /** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */ export function ci(term: string) { return { contains: term, mode: 'insensitive' as const }; diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index 70d6b3a..dbbabbe 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -25,7 +25,7 @@ import { } from '../common/record-scope'; import { normalizeTags } from '../common/tags'; import { normalizePersonImportSource } from './person-source'; -import { buildSearchWhere, ci } from '../common/search'; +import { buildSearchWhere, ci, splitCsv } from '../common/search'; import { GamificationService } from '../gamification/gamification.service'; import { CustomerGroupsService, @@ -105,10 +105,10 @@ export class CompaniesService { { city: ci(t) }, { province: ci(t) }, ]); - const groupId = opts?.groupId?.trim(); + const groupIds = splitCsv(opts?.groupId); const and: Prisma.CompanyWhereInput[] = [ ...(search ? [search] : []), - ...(groupId ? [{ groupLinks: { some: { groupId } } }] : []), + ...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []), ]; const where: Prisma.CompanyWhereInput = { organizationId: actor.organizationId, diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index d03b8fd..fd133ff 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -50,18 +50,20 @@ export class ListQueryDto { @Max(100) limit?: number = 20; + /** وضعیت مشتری؛ چندتایی با کاما */ @IsOptional() - @IsIn([...PERSON_MOODS]) - mood?: (typeof PERSON_MOODS)[number]; + @IsString() + @MaxLength(120) + mood?: string; @IsOptional() @IsIn(['name', 'newest']) sort?: 'name' | 'newest'; - /** فیلتر منبع ورود شخص */ + /** فیلتر منبع ورود شخص؛ چندتایی با کاما */ @IsOptional() @IsString() - @MaxLength(40) + @MaxLength(400) importSource?: string; /** استان شرکت مرتبط */ @@ -87,10 +89,10 @@ export class ListQueryDto { @MaxLength(12) disc?: string; - /** فیلتر گروه مشتری */ + /** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروه‌ها) */ @IsOptional() @IsString() - @MaxLength(40) + @MaxLength(2000) groupId?: string; } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 0e768e8..151192c 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -17,7 +17,7 @@ import type { PersonMood, UpdatePersonRequest, } from '@mento/shared'; -import { FILTERED_IDS_MAX } from '@mento/shared'; +import { FILTERED_IDS_MAX, PERSON_MOODS } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; @@ -45,7 +45,7 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { selectMonthBirthdays } from './birthday-month'; import { renderSmsText } from '../common/sms-text'; import { normalizeTags } from '../common/tags'; -import { buildSearchWhere, ci } from '../common/search'; +import { buildSearchWhere, ci, splitCsv } from '../common/search'; import { normalizePersonImportSource, personImportSourceLabel, @@ -150,7 +150,7 @@ export class PeopleService implements OnModuleInit { actor: Actor, q?: string, opts?: { - mood?: PersonMood; + mood?: string; importSource?: string; province?: string; city?: string; @@ -159,9 +159,11 @@ export class PeopleService implements OnModuleInit { groupId?: string; }, ): Prisma.PersonWhereInput { - const sourceFilter = opts?.importSource?.trim() - ? normalizePersonImportSource(opts.importSource) - : undefined; + const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))]; + const moods = splitCsv(opts?.mood).filter((m): m is PersonMood => + (PERSON_MOODS as readonly string[]).includes(m), + ); + const groupIds = splitCsv(opts?.groupId); const search = buildSearchWhere(q, (t) => [ { firstName: ci(t) }, { lastName: ci(t) }, @@ -201,13 +203,12 @@ export class PeopleService implements OnModuleInit { } const disc = discFilter(opts?.disc); if (disc) and.push(disc); - const groupId = opts?.groupId?.trim(); - if (groupId) and.push({ groupLinks: { some: { groupId } } }); + if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); return { organizationId: actor.organizationId, ...ownerWhere(actor), - ...(opts?.mood ? { mood: opts.mood } : {}), - ...(sourceFilter ? { importSource: sourceFilter } : {}), + ...(moods.length ? { mood: { in: moods } } : {}), + ...(sources.length ? { importSource: { in: sources } } : {}), ...(and.length ? { AND: and } : {}), }; } @@ -218,7 +219,7 @@ export class PeopleService implements OnModuleInit { page = 1, limit = 20, opts?: { - mood?: PersonMood; + mood?: string; sort?: 'name' | 'newest'; importSource?: string; province?: string; @@ -257,7 +258,7 @@ export class PeopleService implements OnModuleInit { actor: Actor, q?: string, opts?: { - mood?: PersonMood; + mood?: string; importSource?: string; province?: string; city?: string; diff --git a/apps/web/src/components/ContactsFilterPanel.vue b/apps/web/src/components/ContactsFilterPanel.vue new file mode 100644 index 0000000..8ec2e77 --- /dev/null +++ b/apps/web/src/components/ContactsFilterPanel.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 903091f..6d8068f 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -34,7 +34,6 @@ import type { import { brand, PERSON_IMPORT_SOURCE_LABELS, - PERSON_IMPORT_SOURCES, } from '@mento/shared'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; import { canUseBehavior } from '@/lib/behavior-access'; @@ -43,6 +42,7 @@ import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize'; import CustomerGroupChips from '@/components/CustomerGroupChips.vue'; import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue'; import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups'; +import ContactsFilterPanel, { type ContactsFilterValue } from '@/components/ContactsFilterPanel.vue'; import { canManageSettings } from '@/lib/roles'; type Tab = 'people' | 'companies'; @@ -61,35 +61,53 @@ const page = computed(() => { return Number.isFinite(raw) && raw > 0 ? raw : 1; }); const sort = computed(() => (route.query.sort === 'newest' ? 'newest' : 'name')); -const moodFilter = computed(() => { - const value = typeof route.query.mood === 'string' ? route.query.mood : 'all'; - return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all'; -}); +function queryList(key: string): string[] { + const raw = route.query[key]; + if (typeof raw !== 'string') return []; + return [...new Set(raw.split(',').map((v) => v.trim()).filter(Boolean))]; +} +function csv(list: string[]): string | undefined { + return list.length ? list.join(',') : undefined; +} + +const moodFilters = computed(() => + queryList('mood').filter((m): m is PersonMood => PERSON_MOODS.includes(m as PersonMood)), +); const auth = useAuthStore(); const canBehavior = computed(() => canUseBehavior(auth.user)); const discFilter = computed(() => { const value = typeof route.query.disc === 'string' ? route.query.disc.toUpperCase() : ''; return /^[DISC](\/[DISC]){0,3}$/.test(value) ? value : ''; }); -const sourceFilter = computed(() => { - const value = typeof route.query.source === 'string' ? route.query.source : 'all'; - return PERSON_IMPORT_SOURCES.includes(value as PersonImportSource) - ? (value as PersonImportSource) - : 'all'; -}); -const groupFilter = computed(() => - typeof route.query.group === 'string' ? route.query.group : '', -); +const sourceFilters = computed(() => queryList('source')); +const groupFilters = computed(() => queryList('group')); const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups(); const canManageGroups = computed(() => canManageSettings(auth.user?.role)); const groupsManagerOpen = ref(false); -const visibleGroups = computed(() => - customerGroups.value.filter( - (g) => - g.id === groupFilter.value || - (tab.value === 'people' ? g.personCount : g.companyCount) > 0, - ), -); + +const filterValue = computed(() => ({ + groups: groupFilters.value, + sources: sourceFilters.value, + moods: moodFilters.value, + disc: discFilter.value, +})); + +type FilterTag = { kind: 'group' | 'source' | 'mood' | 'disc'; value: string; label: string; color?: string }; +const activeTags = computed(() => { + const tags: FilterTag[] = groupFilters.value.map((id) => { + const g = customerGroups.value.find((x) => x.id === id); + return { kind: 'group', value: id, label: g?.name ?? 'گروه حذف‌شده', color: g?.color }; + }); + if (tab.value !== 'people') return tags; + for (const s of sourceFilters.value) { + tags.push({ kind: 'source', value: s, label: `منبع: ${sourceLabel(s)}` }); + } + for (const m of moodFilters.value) { + tags.push({ kind: 'mood', value: m, label: moodLabel(m) }); + } + if (discFilter.value) tags.push({ kind: 'disc', value: discFilter.value, label: `DISC: ${discFilter.value}` }); + return tags; +}); const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const loading = ref(false); const busy = ref(false); @@ -159,32 +177,27 @@ function onPageSize(size: number) { const peopleSavedFilters = computed(() => ({ q: q.value.trim() || undefined, sort: sort.value === 'name' ? undefined : sort.value, - mood: moodFilter.value === 'all' ? undefined : moodFilter.value, - source: sourceFilter.value === 'all' ? undefined : sourceFilter.value, + mood: csv(moodFilters.value), + source: csv(sourceFilters.value), disc: discFilter.value || undefined, - group: groupFilter.value || undefined, + group: csv(groupFilters.value), })); +function savedText(raw: unknown): string | undefined { + return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +} + function applyPeopleSavedFilters(filters: Record) { const nextQ = typeof filters.q === 'string' ? filters.q : ''; q.value = nextQ; const nextSort = filters.sort === 'newest' ? 'newest' : 'name'; - const nextMood = - typeof filters.mood === 'string' && - PERSON_MOODS.includes(filters.mood as PersonMood) - ? (filters.mood as PersonMood) - : 'all'; - const nextSource = - typeof filters.source === 'string' && - PERSON_IMPORT_SOURCES.includes(filters.source as PersonImportSource) - ? (filters.source as PersonImportSource) - : 'all'; patchQuery({ q: nextQ.trim() || undefined, sort: nextSort === 'name' ? undefined : nextSort, - mood: nextMood === 'all' ? undefined : nextMood, - source: nextSource === 'all' ? undefined : nextSource, - group: typeof filters.group === 'string' && filters.group ? filters.group : undefined, + mood: savedText(filters.mood), + source: savedText(filters.source), + disc: savedText(filters.disc), + group: savedText(filters.group), page: undefined, tab: undefined, }); @@ -250,11 +263,10 @@ async function load() { page: page.value, limit: pageSize.value, sort: sort.value, - mood: moodFilter.value === 'all' ? undefined : moodFilter.value, - importSource: - sourceFilter.value === 'all' ? undefined : sourceFilter.value, + mood: csv(moodFilters.value), + importSource: csv(sourceFilters.value), disc: discFilter.value || undefined, - groupId: groupFilter.value || undefined, + groupId: csv(groupFilters.value), }, }), api.get('/people/source-stats').catch(() => ({ @@ -271,7 +283,7 @@ async function load() { q: q.value || undefined, page: page.value, limit: pageSize.value, - groupId: groupFilter.value || undefined, + groupId: csv(groupFilters.value), }, }); companies.value = data; @@ -299,7 +311,6 @@ function setTab(next: Tab) { patchQuery({ tab: next === 'people' ? undefined : next, page: undefined, - mood: next === 'people' ? (moodFilter.value === 'all' ? undefined : moodFilter.value) : undefined, }); } @@ -312,30 +323,38 @@ function setSort(next: SortKey) { patchQuery({ sort: next === 'name' ? undefined : next, page: undefined }); } -function setMood(next: PersonMood | 'all') { +function applyFilters(next: ContactsFilterValue) { filterOpen.value = false; - patchQuery({ mood: next === 'all' ? undefined : next, page: undefined }); + const people = tab.value === 'people'; + patchQuery({ + group: csv(next.groups), + ...(people + ? { source: csv(next.sources), mood: csv(next.moods), disc: next.disc || undefined } + : {}), + page: undefined, + }); } -function setDisc(next: string) { - patchQuery({ disc: next || undefined, page: undefined }); +function removeTag(tag: FilterTag) { + const without = (list: string[]) => csv(list.filter((v) => v !== tag.value)); + if (tag.kind === 'group') patchQuery({ group: without(groupFilters.value), page: undefined }); + else if (tag.kind === 'source') patchQuery({ source: without(sourceFilters.value), page: undefined }); + else if (tag.kind === 'mood') patchQuery({ mood: without(moodFilters.value), page: undefined }); + else patchQuery({ disc: undefined, page: undefined }); } -function setGroup(next: string) { - patchQuery({ group: next || undefined, page: undefined }); +function clearFilters() { + patchQuery({ + group: undefined, + ...(tab.value === 'people' ? { source: undefined, mood: undefined, disc: undefined } : {}), + page: undefined, + }); } async function onGroupsChanged() { await load(); } -function setSource(next: PersonImportSource | 'all') { - patchQuery({ - source: next === 'all' ? undefined : next, - page: undefined, - }); -} - function setPage(next: number) { if (next < 1 || next > pageCount.value) return; patchQuery({ page: next === 1 ? undefined : String(next) }); @@ -541,22 +560,38 @@ onMounted(load);
  • -
    - - +
    + @@ -568,60 +603,20 @@ onMounted(load); @apply="applyPeopleSavedFilters" /> -
    - - -
    - -
    - - - -
    - -
    - - + {{ tag.label }} + + +

    {{ importMsg }}

    @@ -1076,10 +1071,64 @@ h1 { font-style: normal; font-weight: 700; } -.manage-chip { - border-style: dashed; +.filter-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} +.filter-tags-label { + font-size: 0.8rem; color: #64748b; } +.filter-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.35rem 0.2rem 0.6rem; + border-radius: 999px; + background: #f0fdfa; + color: #0f766e; + font-size: 0.8rem; + font-weight: 600; +} +.filter-tag button { + display: inline-grid; + place-items: center; + width: 1.1rem; + height: 1.1rem; + border: 0; + border-radius: 50%; + background: rgba(15, 23, 42, 0.08); + color: inherit; + cursor: pointer; + padding: 0; +} +.filter-clear { + border: 0; + background: none; + color: #64748b; + font: inherit; + font-size: 0.8rem; + text-decoration: underline; + cursor: pointer; +} +.tool.active { + border-color: #14b8a6; + color: #0f766e; + background: #f0fdfa; +} +.tool-count { + min-width: 1.15rem; + height: 1.15rem; + border-radius: 999px; + background: #14b8a6; + color: #fff; + font-size: 0.7rem; + display: inline-grid; + place-items: center; + padding: 0 0.25rem; +} .tiny { font-size: 0.75rem; margin-top: 0.15rem; From 1c2da3498339a7d8023edf5c7a4b7bc0b05b33ce Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 18:46:12 +0330 Subject: [PATCH 05/65] fix: persist and re-apply contact saved filters correctly --- apps/web/src/components/SavedFiltersBar.vue | 66 +++++++++++++++++++-- apps/web/src/views/ContactsView.vue | 66 ++++++++++++++------- 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/SavedFiltersBar.vue b/apps/web/src/components/SavedFiltersBar.vue index ab2aa50..0f6a2b1 100644 --- a/apps/web/src/components/SavedFiltersBar.vue +++ b/apps/web/src/components/SavedFiltersBar.vue @@ -39,6 +39,8 @@ const busy = ref(false); const saveOpen = ref(false); const saveName = ref(''); const saveShared = ref(false); +/** عکس فیلترهای اعمال‌شده در لحظهٔ باز شدن مودال ذخیره */ +const saveFilters = ref>({}); const activeView = computed( () => views.value.find((v) => v.id === activeId.value) ?? null, @@ -51,6 +53,41 @@ const selectOptions = computed(() => })), ); +const saveFilterCount = computed(() => Object.keys(saveFilters.value).length); + +const saveFilterSummary = computed(() => { + const parts: string[] = []; + const f = saveFilters.value; + if (typeof f.q === 'string' && f.q) parts.push(`جستجو: ${f.q}`); + if (typeof f.group === 'string' && f.group) { + parts.push(`گروه (${f.group.split(',').filter(Boolean).length})`); + } + if (typeof f.source === 'string' && f.source) { + parts.push(`منبع (${f.source.split(',').filter(Boolean).length})`); + } + if (typeof f.mood === 'string' && f.mood) { + parts.push(`وضعیت (${f.mood.split(',').filter(Boolean).length})`); + } + if (typeof f.disc === 'string' && f.disc) parts.push(`DISC: ${f.disc}`); + if (typeof f.sort === 'string' && f.sort) parts.push(`مرتب‌سازی: ${f.sort}`); + if (typeof f.viewMode === 'string' && f.viewMode) parts.push(`نمایش: ${f.viewMode}`); + if (typeof f.pipelineId === 'string' && f.pipelineId) parts.push('مسیر فروش'); + if (f.amountMin != null && f.amountMin !== '') parts.push(`حداقل مبلغ`); + if (f.amountMax != null && f.amountMax !== '') parts.push(`حداکثر مبلغ`); + return parts.length ? parts.join(' · ') : 'هیچ فیلتری اعمال نشده'; +}); + +/** فقط کلیدهای پر؛ برای JSON و جلوگیری از ذخیرهٔ خالی */ +function compactFilters(raw: Record | null | undefined): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(raw ?? {})) { + if (value == null || value === '') continue; + if (Array.isArray(value) && value.length === 0) continue; + out[key] = value; + } + return out; +} + async function loadViews() { loading.value = true; try { @@ -71,10 +108,19 @@ function onPick(id: unknown) { activeId.value = next || undefined; if (!next) return; const view = views.value.find((v) => v.id === next); - if (view) emit('apply', view.filters ?? {}); + if (!view) return; + // کپی ساده تا مصرف‌کننده همان آبجکت ری‌اکتیو را دستکاری نکند + emit('apply', compactFilters(view.filters ?? {})); } function openSave() { + saveFilters.value = compactFilters(props.currentFilters); + if (!Object.keys(saveFilters.value).length) { + message.warning( + 'ابتدا فیلترها را از دکمهٔ «فیلتر» اعمال کنید (یا جستجو بزنید)، بعد ذخیره کنید.', + ); + return; + } saveName.value = activeView.value?.name ?? ''; saveShared.value = activeView.value?.isShared ?? false; saveOpen.value = true; @@ -83,12 +129,17 @@ function openSave() { async function saveView() { const name = saveName.value.trim(); if (name.length < 2 || busy.value) return; + const filters = compactFilters(saveFilters.value); + if (!Object.keys(filters).length) { + message.warning('چیزی برای ذخیره نیست؛ اول فیلتر را روی صفحه اعمال کنید.'); + return; + } busy.value = true; try { const body = { name, scope: props.scope, - filters: props.currentFilters, + filters, isShared: saveShared.value, }; if (activeId.value) { @@ -160,11 +211,11 @@ onMounted(() => { :disabled="loading" :loading="loading" :options="selectOptions" - :placeholder="compact ? 'فیلتر ذخیره‌شده' : '— انتخاب —'" + :placeholder="compact ? 'فیلتر ذخیره‌شده' : 'فیلتر ذخیره‌شده را انتخاب کنید'" :style="{ flex: 1, minWidth: 0, width: '100%' }" @update:value="onPick" /> - + ذخیره @@ -193,9 +244,14 @@ onMounted(() => { + +

    + {{ saveFilterSummary }} +

    +
    اشتراک با سازمان diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index 6d8068f..ca4b3d4 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -174,32 +174,58 @@ function onPageSize(size: number) { if (page.value === 1) void load(); } -const peopleSavedFilters = computed(() => ({ - q: q.value.trim() || undefined, - sort: sort.value === 'name' ? undefined : sort.value, - mood: csv(moodFilters.value), - source: csv(sourceFilters.value), - disc: discFilter.value || undefined, - group: csv(groupFilters.value), -})); +const peopleSavedFilters = computed(() => { + const out: Record = {}; + const nextQ = q.value.trim(); + if (nextQ) out.q = nextQ; + if (sort.value !== 'name') out.sort = sort.value; + const mood = csv(moodFilters.value); + if (mood) out.mood = mood; + const source = csv(sourceFilters.value); + if (source) out.source = source; + if (discFilter.value) out.disc = discFilter.value; + const group = csv(groupFilters.value); + if (group) out.group = group; + return out; +}); -function savedText(raw: unknown): string | undefined { - return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; +/** مقدار ذخیره‌شده: رشته یا آرایه → رشتهٔ کاما‌جدا برای URL */ +function savedCsv(raw: unknown): string | undefined { + if (Array.isArray(raw)) { + const list = raw.map((v) => String(v).trim()).filter(Boolean); + return list.length ? list.join(',') : undefined; + } + if (typeof raw === 'string' && raw.trim()) return raw.trim(); + return undefined; } function applyPeopleSavedFilters(filters: Record) { const nextQ = typeof filters.q === 'string' ? filters.q : ''; q.value = nextQ; - const nextSort = filters.sort === 'newest' ? 'newest' : 'name'; - patchQuery({ - q: nextQ.trim() || undefined, - sort: nextSort === 'name' ? undefined : nextSort, - mood: savedText(filters.mood), - source: savedText(filters.source), - disc: savedText(filters.disc), - group: savedText(filters.group), - page: undefined, - tab: undefined, + // کلیدهای فیلتر را از نو می‌سازیم تا فیلتر قبلی روی صفحه نماند + const query: Record = {}; + for (const [key, value] of Object.entries(route.query)) { + if (['q', 'sort', 'mood', 'source', 'disc', 'group', 'page', 'tab'].includes(key)) continue; + if (value == null || value === '') continue; + if (Array.isArray(value)) { + const list = value.filter((v): v is string => typeof v === 'string' && v !== ''); + if (list.length) query[key] = list; + } else if (typeof value === 'string') { + query[key] = value; + } + } + if (nextQ.trim()) query.q = nextQ.trim(); + if (filters.sort === 'newest') query.sort = 'newest'; + const mood = savedCsv(filters.mood); + if (mood) query.mood = mood; + const source = savedCsv(filters.source); + if (source) query.source = source; + const disc = savedCsv(filters.disc); + if (disc) query.disc = disc; + const group = savedCsv(filters.group); + if (group) query.group = group; + void router.replace({ name: 'contacts', query }).then(() => { + void load(); }); } From 23ae273232e59858918a1f963287ea2702988ecd Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 20:01:16 +0330 Subject: [PATCH 06/65] feat(contacts): compact layout, mobile filter sheet and redesigned cards --- .../src/components/ContactsFilterPanel.vue | 150 ++- apps/web/src/views/ContactsView.vue | 1077 +++++++++++------ 2 files changed, 815 insertions(+), 412 deletions(-) diff --git a/apps/web/src/components/ContactsFilterPanel.vue b/apps/web/src/components/ContactsFilterPanel.vue index 8ec2e77..066fe14 100644 --- a/apps/web/src/components/ContactsFilterPanel.vue +++ b/apps/web/src/components/ContactsFilterPanel.vue @@ -1,5 +1,5 @@ diff --git a/apps/web/src/views/ContactsView.vue b/apps/web/src/views/ContactsView.vue index ca4b3d4..34ca751 100644 --- a/apps/web/src/views/ContactsView.vue +++ b/apps/web/src/views/ContactsView.vue @@ -1,5 +1,5 @@ - - diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index cd1f4e4..8479516 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -241,69 +241,124 @@ export const router = createRouter({ }, { path: 'finance', - name: 'finance', - component: () => import('@/views/treasury/TreasuryHomeView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/accounts', - name: 'finance-accounts', - component: () => import('@/views/treasury/TreasuryAccountsView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/accounts/:id', - name: 'finance-account', - component: () => import('@/views/treasury/TreasuryAccountView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/incomes', - name: 'finance-incomes', - component: () => import('@/views/treasury/TreasuryIncomesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/expenses', - name: 'finance-expenses', - component: () => import('@/views/treasury/TreasuryExpensesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/receivables', - name: 'finance-receivables', - component: () => import('@/views/treasury/TreasuryReceivablesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/payroll', - name: 'finance-payroll', - component: () => import('@/views/treasury/TreasuryPayrollView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/recurring', - name: 'finance-recurring', - component: () => import('@/views/treasury/TreasuryRecurringView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/reports', - name: 'finance-reports', - component: () => import('@/views/treasury/TreasuryReportsView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/audit', - name: 'finance-audit', - component: () => import('@/views/treasury/TreasuryAuditView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/categories', - name: 'finance-categories', - component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), + component: () => import('@/views/treasury/TreasuryLayout.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, + children: [ + { + path: '', + name: 'finance', + component: () => import('@/views/treasury/TreasuryHomeView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'مدیریت مالی', + treasuryDescription: + 'دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.', + }, + }, + { + path: 'accounts', + name: 'finance-accounts', + component: () => import('@/views/treasury/TreasuryAccountsView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'صندوق و حساب‌ها', + treasuryDescription: 'موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.', + }, + }, + { + path: 'accounts/:id', + name: 'finance-account', + component: () => import('@/views/treasury/TreasuryAccountView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'جزئیات حساب', + treasuryDescription: 'گردش قطعی این حساب', + }, + }, + { + path: 'incomes', + name: 'finance-incomes', + component: () => import('@/views/treasury/TreasuryIncomesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'دریافت‌ها و درآمدها', + treasuryDescription: + 'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.', + }, + }, + { + path: 'expenses', + name: 'finance-expenses', + component: () => import('@/views/treasury/TreasuryExpensesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'پرداخت‌ها و هزینه‌ها', + treasuryDescription: + 'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.', + }, + }, + { + path: 'receivables', + name: 'finance-receivables', + component: () => import('@/views/treasury/TreasuryReceivablesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'اقساط و مطالبات', + treasuryDescription: + 'دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.', + }, + }, + { + path: 'payroll', + name: 'finance-payroll', + component: () => import('@/views/treasury/TreasuryPayrollView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'حقوق و دستمزد', + treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.', + }, + }, + { + path: 'recurring', + name: 'finance-recurring', + component: () => import('@/views/treasury/TreasuryRecurringView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'درآمد و هزینه تکرارشونده', + treasuryDescription: 'تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.', + }, + }, + { + path: 'reports', + name: 'finance-reports', + component: () => import('@/views/treasury/TreasuryReportsView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'گزارش مالی', + treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', + }, + }, + { + path: 'audit', + name: 'finance-audit', + component: () => import('@/views/treasury/TreasuryAuditView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'سوابق تغییرات مالی', + treasuryDescription: 'تسویه سند و نشستن وجه در صندوق، با مقدار ثبت‌شده.', + }, + }, + { + path: 'categories', + name: 'finance-categories', + component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'دسته‌بندی‌های مالی', + treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', + }, + }, + ], }, { path: 'reservations', diff --git a/apps/web/src/views/treasury/TreasuryAccountView.vue b/apps/web/src/views/treasury/TreasuryAccountView.vue index efcdccd..7b102ed 100644 --- a/apps/web/src/views/treasury/TreasuryAccountView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountView.vue @@ -1,121 +1,203 @@ + + + بازگشت به حساب‌ها + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + مغایرت و اصلاح + + + + + + + + + + + + + + + + ثبت مغایرت + اصلاح ورود + اصلاح خروج + + + + + + + گردش حساب + + + + + diff --git a/apps/web/src/views/treasury/TreasuryAccountsView.vue b/apps/web/src/views/treasury/TreasuryAccountsView.vue index f5de95e..ba17e20 100644 --- a/apps/web/src/views/treasury/TreasuryAccountsView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountsView.vue @@ -20,13 +20,16 @@ import { Statistic as AStatistic, Tag as ATag, Typography, + message, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; +import JalaliDateInput from '@/components/JalaliDateInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -82,14 +85,19 @@ async function load() { accounts.value = data; } +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + async function createAccount() { saving.value = true; error.value = ''; ok.value = ''; try { + const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0; await api.post('/treasury/accounts', { ...form.value, - openingBalance: form.value.openingBalance || 0, + openingBalance, openingDate: form.value.openingDate || undefined, }); openForm.value = false; @@ -117,11 +125,20 @@ async function sendTransfer() { error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(transfer.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ انتقال معتبر نیست.'; + return; + } await api.post('/treasury/transfers', { - ...transfer.value, + fromAccountId: transfer.value.fromAccountId, + toAccountId: transfer.value.toAccountId, + amount, + memo: transfer.value.memo || undefined, clientKey: crypto.randomUUID(), }); transferOpen.value = false; + transfer.value = { fromAccountId: '', toAccountId: '', amount: '', memo: '' }; ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.'; await load(); } catch (e) { @@ -139,17 +156,7 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue index 66f8c09..05b3b2b 100644 --- a/apps/web/src/views/treasury/TreasuryCategoriesView.vue +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -16,20 +16,16 @@ import { Row as ARow, Select as ASelect, Tag as ATag, - Typography, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialCategoryDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; const rows = ref([]); -const name = ref(''); -const kind = ref<'income' | 'expense'>('expense'); +const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' }); const error = ref(''); async function load() { @@ -40,8 +36,11 @@ async function load() { async function add() { error.value = ''; try { - await api.post('/treasury/categories', { name: name.value, kind: kind.value }); - name.value = ''; + await api.post('/treasury/categories', { + name: form.value.name, + kind: form.value.kind, + }); + form.value.name = ''; await load(); } catch (e) { error.value = axios.isAxiosError(e) @@ -56,31 +55,25 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 21a0678..338c75e 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -16,13 +16,15 @@ import { Row as ARow, Select as ASelect, Typography, + message, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -47,7 +49,6 @@ const form = ref({ notes: '', }); -const title = 'پرداخت‌ها و هزینه‌ها'; const settleLabel = 'مبلغ پرداخت‌شده'; const statusLabel: Record = { open: 'پرداخت‌نشده', @@ -81,19 +82,29 @@ async function load() { accounts.value = acc.data.filter((row) => row.isActive); } +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + async function createDoc() { saving.value = true; error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ کل معتبر نیست.'; + return; + } + const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; await api.post('/treasury/documents', { kind, title: form.value.title, - amount: form.value.amount, + amount, categoryId: form.value.categoryId || null, partyName: form.value.partyName || null, accountId: form.value.accountId || null, - settleAmount: form.value.settleAmount || 0, + settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, @@ -136,43 +147,50 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/TreasuryHomeView.vue b/apps/web/src/views/treasury/TreasuryHomeView.vue index 088d07e..2995869 100644 --- a/apps/web/src/views/treasury/TreasuryHomeView.vue +++ b/apps/web/src/views/treasury/TreasuryHomeView.vue @@ -14,10 +14,9 @@ import { } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { TreasuryDashboardDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import { toman } from './treasury-format'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; @@ -46,17 +45,7 @@ onMounted(async () => { diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index e8c78d0..06d3480 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -22,12 +22,12 @@ import { Space as ASpace, Textarea as ATextarea, Typography, + message, theme, } from 'ant-design-vue'; import type { FormInstance } from 'ant-design-vue'; import { ArrowDownOutlined, - ArrowLeftOutlined, BankOutlined, CheckOutlined, ClockCircleOutlined, @@ -48,10 +48,11 @@ import type { TreasuryDashboardDto, } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -91,7 +92,6 @@ function emptyForm() { const form = ref(emptyForm()); -const pageTitle = 'دریافت‌ها و درآمدها'; const settleLabel = 'مبلغ دریافت‌شده'; const statusLabel: Record = { open: 'دریافت‌نشده', @@ -122,11 +122,15 @@ const formTabOptions = [ ]; const remainingAmount = computed(() => { - const total = Number(form.value.amount) || 0; - const settled = Number(form.value.settleAmount) || 0; + const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0; + const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; return Math.max(0, total - settled); }); +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + function apiMessage(e: unknown) { if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.'; const msg = (e.response?.data as { message?: string | string[] })?.message; @@ -158,14 +162,20 @@ async function createDoc() { error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ کل معتبر نیست.'; + return; + } + const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; await api.post('/treasury/documents', { kind, title: form.value.title, - amount: form.value.amount, + amount, categoryId: form.value.categoryId || null, partyName: form.value.partyName || null, accountId: form.value.accountId || null, - settleAmount: form.value.settleAmount || 0, + settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, @@ -243,18 +253,6 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryReportsView.vue b/apps/web/src/views/treasury/TreasuryReportsView.vue index 01a56e8..8d3ac8c 100644 --- a/apps/web/src/views/treasury/TreasuryReportsView.vue +++ b/apps/web/src/views/treasury/TreasuryReportsView.vue @@ -14,10 +14,9 @@ import { import type { TableColumnsType } from 'ant-design-vue'; import { DownloadOutlined } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import { toman } from './treasury-format'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; @@ -100,23 +99,14 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/treasuryPageChrome.ts b/apps/web/src/views/treasury/treasuryPageChrome.ts new file mode 100644 index 0000000..8ba653d --- /dev/null +++ b/apps/web/src/views/treasury/treasuryPageChrome.ts @@ -0,0 +1,33 @@ +import { inject, provide, ref, type InjectionKey, type Ref } from 'vue'; + +export type TreasuryChromeOverride = { + title: string | null; + description: string | null; +}; + +type TreasuryChromeApi = { + override: Ref; + set: (next: TreasuryChromeOverride) => void; + clear: () => void; +}; + +const KEY: InjectionKey = Symbol('treasuryPageChrome'); + +export function provideTreasuryPageChrome(): TreasuryChromeApi { + const override = ref({ title: null, description: null }); + const api: TreasuryChromeApi = { + override, + set(next) { + override.value = next; + }, + clear() { + override.value = { title: null, description: null }; + }, + }; + provide(KEY, api); + return api; +} + +export function useTreasuryPageChrome(): TreasuryChromeApi | null { + return inject(KEY, null); +} From 09a60b3b5a859c200107642fe6b1162ea4694aa4 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 22 Oct 2026 11:53:26 +0330 Subject: [PATCH 08/65] feat(acl): deep data access by section for roles and users Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets. --- .../migration.sql | 27 + apps/api/prisma/schema.prisma | 4 + apps/api/src/ai/ai-ask.service.ts | 4 + apps/api/src/auth/jwt.strategy.ts | 36 ++ apps/api/src/common/data-access.ts | 465 +++++++++++++++ apps/api/src/common/record-scope.ts | 70 ++- apps/api/src/contacts/companies.service.ts | 6 +- .../src/contacts/contacts-excel.service.ts | 11 +- apps/api/src/contacts/people.service.ts | 12 +- apps/api/src/leads/leads.service.ts | 26 +- .../src/memberships/memberships.service.ts | 4 +- apps/api/src/org-roles/dto.ts | 9 + apps/api/src/org-roles/org-roles.service.ts | 18 +- .../project-boards/project-boards.service.ts | 8 +- apps/api/src/routines/routine-scope.ts | 24 +- .../api/src/routines/routine-tasks.service.ts | 26 +- .../sms-campaigns/sms-campaigns.service.ts | 4 + apps/api/src/users/dto.ts | 11 + apps/api/src/users/users.controller.ts | 16 + apps/api/src/users/users.service.ts | 48 +- apps/web/src/components/DataAccessEditor.vue | 542 ++++++++++++++++++ .../src/components/DataAccessPolicyPanel.vue | 252 ++++++++ .../web/src/components/TeamsSettingsPanel.vue | 171 +++--- apps/web/src/views/TeamSettingsView.vue | 92 +++ packages/shared/src/index.ts | 233 ++++++++ 25 files changed, 1997 insertions(+), 122 deletions(-) create mode 100644 apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql create mode 100644 apps/api/src/common/data-access.ts create mode 100644 apps/web/src/components/DataAccessEditor.vue create mode 100644 apps/web/src/components/DataAccessPolicyPanel.vue diff --git a/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql b/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql new file mode 100644 index 0000000..1a0602d --- /dev/null +++ b/apps/api/prisma/migrations/20261022120000_deep_data_access/migration.sql @@ -0,0 +1,27 @@ +-- Deep data access: role default + membership override (phase 1: customers) + +ALTER TABLE "OrgRole" ADD COLUMN IF NOT EXISTS "dataAccessPolicy" JSONB NOT NULL DEFAULT '{}'; + +ALTER TABLE "Membership" ADD COLUMN IF NOT EXISTS "dataAccessOverride" JSONB NOT NULL DEFAULT '{}'; + +-- Migrate legacy own/team/org (+ optional contacts scope) into customers override +UPDATE "Membership" m +SET "dataAccessOverride" = jsonb_build_object( + 'customers', + CASE + WHEN COALESCE( + NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''), + m."dataVisibility" + ) = 'org' THEN '{"mode":"all","rules":[]}'::jsonb + WHEN COALESCE( + NULLIF(m."dataVisibilityScopes"::jsonb ->> 'contacts', ''), + m."dataVisibility" + ) = 'team' THEN '{"mode":"rules","rules":[{"owners":["self","team"]}]}'::jsonb + ELSE '{"mode":"rules","rules":[{"owners":["self"]}]}'::jsonb + END +) +WHERE m."role" NOT IN ('owner', 'admin') + AND ( + m."dataAccessOverride" IS NULL + OR m."dataAccessOverride"::text IN ('{}', 'null') + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ad290fb..98e6f5b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -338,6 +338,8 @@ model OrgRole { description String @default("") color String @default("teal") permissions String[] @default([]) + /// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}} + dataAccessPolicy Json @default("{}") /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند systemKey String? /// نقش والد برای سلسله‌مراتب زیرنقش‌ها @@ -370,6 +372,8 @@ model Membership { dataVisibility String @default("own") /// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility dataVisibilityScopes Json @default("{}") + /// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy + dataAccessOverride Json @default("{}") /// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد priorArchiveEnabled Boolean @default(false) /// current | archive diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 8cae72a..1064db8 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -9,6 +9,7 @@ import type { VoicePersonDraft, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { leadsDeepWhere } from '../common/data-access'; import { type Actor, assigneeWhere, @@ -260,6 +261,9 @@ Rules: } private leadWhere(actor: Actor) { + if (actor.leadsAccess !== undefined) { + return leadsDeepWhere(actor); + } const scoped = forScope(actor, 'leads'); if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {}; if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) { diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 6e16234..83595b1 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import type { DataVisibility } from '@mento/shared'; import { BillingService } from '../billing/billing.service'; +import { + resolveAllSectionAccess, + sectionNeedsTeamPeers, +} from '../common/data-access'; import { type Actor, normalizeDataVisibility, @@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { role: true, dataVisibility: true, dataVisibilityScopes: true, + dataAccessOverride: true, priorArchiveEnabled: true, priorArchiveLens: true, + orgRole: { select: { dataAccessPolicy: true } }, organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } }, }, }); @@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } } + const deepAccess = resolveAllSectionAccess({ + role: membership.role, + rolePolicy: membership.orgRole?.dataAccessPolicy, + override: membership.dataAccessOverride, + legacyVisibility: dataVisibility, + legacyScopes: scopeLevels, + }); + const needsTeamPeers = + !elevatedRole && + (sectionNeedsTeamPeers(deepAccess.customers) || + sectionNeedsTeamPeers(deepAccess.leads) || + sectionNeedsTeamPeers(deepAccess.sales) || + sectionNeedsTeamPeers(deepAccess.activities) || + sectionNeedsTeamPeers(deepAccess.notes) || + sectionNeedsTeamPeers(deepAccess.routines) || + sectionNeedsTeamPeers(deepAccess.projects) || + sectionNeedsTeamPeers(deepAccess.tickets)); + if (needsTeamPeers) { + teamIds ??= await this.teamPeerIds(membership.organizationId, user.id); + } + const modules = this.billing.normalizeModules(membership.organization.enabledModules); const priorGranted = modules.includes('prior_data_archive') && membership.priorArchiveEnabled; @@ -130,6 +157,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) { dataVisibility, visibleUserIds, scopes, + customersAccess: deepAccess.customers, + leadsAccess: deepAccess.leads, + salesAccess: deepAccess.sales, + activitiesAccess: deepAccess.activities, + notesAccess: deepAccess.notes, + routinesAccess: deepAccess.routines, + projectsAccess: deepAccess.projects, + ticketsAccess: deepAccess.tickets, + teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]), priorLens: priorGranted ? priorLens : 'off', priorCutoff: priorGranted && membership.organization.priorArchiveEnabledAt diff --git a/apps/api/src/common/data-access.ts b/apps/api/src/common/data-access.ts new file mode 100644 index 0000000..c848a93 --- /dev/null +++ b/apps/api/src/common/data-access.ts @@ -0,0 +1,465 @@ +import type { + DataAccessPolicy, + DataAccessRule, + DataAccessSection, + DataVisibility, + DataVisibilityScopes, + SectionDataAccess, +} from '@mento/shared'; +import { + DATA_ACCESS_LEGACY_SCOPE, + DATA_ACCESS_SECTIONS, + migrateVisibilityToSectionAccess, + parseDataAccessPolicy, + sectionHasDataAccessLimits, +} from '@mento/shared'; +import { canViewAllRecords } from '@mento/shared'; +import type { Prisma } from '@prisma/client'; +import type { Actor } from './record-scope'; + +function elevated(role: string | null | undefined): boolean { + return canViewAllRecords(role); +} + +export type SectionAccessContext = { + section: SectionDataAccess; + source: 'role' | 'override' | 'default'; +}; + +function ownerIdList( + rule: DataAccessRule, + ctx: { userId: string; teamPeerIds: string[] | null }, +): string[] { + const ownerIds = new Set(); + if (!rule.owners?.length) return []; + if (rule.owners.includes('self')) ownerIds.add(ctx.userId); + if (rule.owners.includes('team')) { + const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId]; + for (const id of peers) ownerIds.add(id); + } + return [...ownerIds]; +} + +/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */ +export function resolveSectionAccess( + sectionKey: DataAccessSection, + input: { + role: string; + rolePolicy?: unknown; + override?: unknown; + legacyVisibility?: DataVisibility | string | null; + legacyScopes?: DataVisibilityScopes | null; + }, +): SectionAccessContext { + if (elevated(input.role)) { + return { section: { mode: 'all', rules: [] }, source: 'default' }; + } + const override = parseDataAccessPolicy(input.override); + if (override[sectionKey]) { + return { section: override[sectionKey]!, source: 'override' }; + } + const rolePolicy = parseDataAccessPolicy(input.rolePolicy); + if (rolePolicy[sectionKey]) { + return { section: rolePolicy[sectionKey]!, source: 'role' }; + } + if (input.legacyVisibility != null || input.legacyScopes) { + return { + section: migrateVisibilityToSectionAccess( + input.legacyVisibility, + input.legacyScopes, + DATA_ACCESS_LEGACY_SCOPE[sectionKey], + ), + source: 'default', + }; + } + return { section: { mode: 'all', rules: [] }, source: 'default' }; +} + +/** @deprecated */ +export function resolveCustomersAccess(input: { + role: string; + rolePolicy?: unknown; + override?: unknown; + legacyVisibility?: DataVisibility | string | null; + legacyScopes?: DataVisibilityScopes | null; +}): SectionAccessContext { + return resolveSectionAccess('customers', input); +} + +export function resolveAllSectionAccess(input: { + role: string; + rolePolicy?: unknown; + override?: unknown; + legacyVisibility?: DataVisibility | string | null; + legacyScopes?: DataVisibilityScopes | null; +}): DataAccessPolicy { + const out: DataAccessPolicy = {}; + for (const key of DATA_ACCESS_SECTIONS) { + out[key] = resolveSectionAccess(key, input).section; + } + return out; +} + +export function buildContactRuleWhere( + rule: DataAccessRule, + ctx: { userId: string; teamPeerIds: string[] | null }, +): Prisma.PersonWhereInput | null { + const and: Prisma.PersonWhereInput[] = []; + const ids = ownerIdList(rule, ctx); + if (rule.owners?.length) { + if (!ids.length) return null; + and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + } + if (rule.groupIds?.length) { + and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } }); + } + if (rule.sources?.length) { + and.push({ + OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], + }); + } + if (rule.moods?.length) { + and.push({ mood: { in: rule.moods } }); + } + if (!and.length) return null; + return and.length === 1 ? and[0]! : { AND: and }; +} + +function orParts(parts: T[]): T | { OR: T[] } | { id: string } { + if (!parts.length) return { id: '__no_access__' } as T & { id: string }; + return parts.length === 1 ? parts[0]! : { OR: parts }; +} + +export function customersPersonWhere( + actor: Pick, +): Prisma.PersonWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.PersonWhereInput[] = []; + for (const rule of section.rules ?? []) { + const w = buildContactRuleWhere(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (w) parts.push(w); + } + return orParts(parts); +} + +export function customersCompanyWhere( + actor: Pick, +): Prisma.CompanyWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.CompanyWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.CompanyWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + } + if (rule.groupIds?.length) { + and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } }); + } + if (rule.sources?.length) { + and.push({ + OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], + }); + } + if ( + rule.moods?.length && + !rule.owners?.length && + !rule.groupIds?.length && + !rule.sources?.length + ) { + continue; + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function leadsDeepWhere( + actor: Pick, +): Prisma.LeadWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.LeadWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.LeadWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + } + if (rule.stageIds?.length) { + and.push({ stageId: { in: rule.stageIds } }); + } + if (rule.sources?.length) { + and.push({ + OR: rule.sources.map((s) => ({ + source: { equals: s, mode: 'insensitive' as const }, + })), + }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function dealsDeepWhere( + actor: Pick, +): Prisma.DealWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.DealWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.DealWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push({ + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }); + } + if (rule.pipelineIds?.length) { + and.push({ pipelineId: { in: rule.pipelineIds } }); + } + if (rule.stageIds?.length) { + and.push({ stageId: { in: rule.stageIds } }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function activitiesDeepWhere( + actor: Pick, +): Prisma.ActivityWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.ActivityWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.ActivityWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push({ + OR: [ + { assigneeId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + { participantIds: { hasSome: ids } }, + ], + }); + } + if (rule.boardIds?.length) { + and.push({ boardId: { in: rule.boardIds } }); + } + if (rule.stageIds?.length) { + and.push({ stageId: { in: rule.stageIds } }); + } + if (rule.typeIds?.length) { + and.push({ typeDefId: { in: rule.typeIds } }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function notesDeepWhere( + actor: Pick, +): Prisma.NoteWhereInput { + const personalGate: Prisma.NoteWhereInput = { + OR: [ + { visibility: { not: 'personal' } }, + { visibility: 'personal', authorId: actor.userId }, + ], + }; + if (elevated(actor.role)) return personalGate; + const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return personalGate; + const parts: Prisma.NoteWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.NoteWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push({ + OR: [ + { authorId: { in: ids } }, + { + deal: { + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }, + }, + { person: { ownerId: { in: ids } } }, + { company: { ownerId: { in: ids } } }, + ], + }); + } + if (rule.sources?.length) { + and.push({ noteType: { in: rule.sources } }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return { + AND: [personalGate, orParts(parts)], + }; +} + +export function ticketsDeepWhere( + actor: Pick, +): Prisma.TicketWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.TicketWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.TicketWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push({ + OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], + }); + } + if (rule.sources?.length) { + and.push({ + status: { + in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>, + }, + }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function projectsDeepWhere( + actor: Pick, +): Prisma.ProjectCardWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.ProjectCardWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.ProjectCardWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push({ + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }); + } + if (rule.boardIds?.length) { + and.push({ boardId: { in: rule.boardIds } }); + } + if (rule.stageIds?.length) { + and.push({ stageId: { in: rule.stageIds } }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +export function routinesDeepWhere( + actor: Pick, +): Prisma.RoutineTaskWhereInput { + if (elevated(actor.role)) return {}; + const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] }; + if (!sectionHasDataAccessLimits(section)) return {}; + const parts: Prisma.RoutineTaskWhereInput[] = []; + for (const rule of section.rules ?? []) { + const and: Prisma.RoutineTaskWhereInput[] = []; + if (rule.owners?.length) { + const ids = ownerIdList(rule, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + if (!ids.length) continue; + and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + } + if (rule.typeIds?.length) { + and.push({ categoryId: { in: rule.typeIds } }); + } + if (!and.length) continue; + parts.push(and.length === 1 ? and[0]! : { AND: and }); + } + return orParts(parts); +} + +/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */ +export function sectionOwnerIdUnion( + section: SectionDataAccess | null | undefined, + ctx: { userId: string; teamPeerIds: string[] | null }, +): string[] | null { + if (!sectionHasDataAccessLimits(section)) return null; + const ids = new Set(); + let sawOwnerRule = false; + let sawNonOwnerRule = false; + for (const rule of section?.rules ?? []) { + if (rule.owners?.length) { + sawOwnerRule = true; + for (const id of ownerIdList(rule, ctx)) ids.add(id); + } else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length || + rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length || + rule.typeIds?.length) { + sawNonOwnerRule = true; + } + } + if (sawNonOwnerRule && !sawOwnerRule) return null; + if (!sawOwnerRule) return null; + return [...ids]; +} + +export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean { + if (!sectionHasDataAccessLimits(section)) return false; + return (section?.rules ?? []).some((r) => r.owners?.includes('team')); +} + +export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy { + return parseDataAccessPolicy(raw); +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 112c41b..00f8ac9 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -1,6 +1,20 @@ -import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared'; +import type { + DataScope, + DataVisibility, + DataVisibilityScopes, + SectionDataAccess, + UserRole, +} from '@mento/shared'; import { DATA_SCOPES, canViewAllRecords } from '@mento/shared'; import type { Prisma } from '@prisma/client'; +import { + activitiesDeepWhere, + dealsDeepWhere, + notesDeepWhere, + projectsDeepWhere, + routinesDeepWhere, + ticketsDeepWhere, +} from './data-access'; export type ScopeVisibility = { dataVisibility: DataVisibility; @@ -20,6 +34,17 @@ export type Actor = { visibleUserIds?: string[] | null; /** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */ scopes?: Partial>; + /** عمق دادهٔ بخش‌ها (resolve شده از نقش + override) */ + customersAccess?: SectionDataAccess; + leadsAccess?: SectionDataAccess; + salesAccess?: SectionDataAccess; + activitiesAccess?: SectionDataAccess; + notesAccess?: SectionDataAccess; + routinesAccess?: SectionDataAccess; + projectsAccess?: SectionDataAccess; + ticketsAccess?: SectionDataAccess; + /** هم‌تیمی‌ها برای قوانین owners:team (شامل خود کاربر) */ + teamPeerIds?: string[] | null; /** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */ priorLens?: 'off' | 'current' | 'archive'; /** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */ @@ -93,10 +118,13 @@ export function ownerWhere( return { ownerId: { in: ids } }; } -/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */ +/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */ export function dealAccessWhere( - actor: ScopedActor, + actor: ScopedActor & Pick, ): Prisma.DealWhereInput { + if (actor.salesAccess !== undefined) { + return dealsDeepWhere(actor as Actor); + } const ids = scopedOwnerIds(actor, 'deals'); if (!ids) return {}; return { @@ -107,10 +135,13 @@ export function dealAccessWhere( }; } -/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */ +/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */ export function projectCardAccessWhere( - actor: ScopedActor, + actor: ScopedActor & Pick, ): Prisma.ProjectCardWhereInput { + if (actor.projectsAccess !== undefined) { + return projectsDeepWhere(actor as Actor); + } const ids = scopedOwnerIds(actor, 'projects'); if (!ids) return {}; return { @@ -135,10 +166,13 @@ export function assigneeWhere( }; } -/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */ +/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */ export function activityAccessWhere( - actor: ScopedActor, + actor: ScopedActor & Pick, ): Prisma.ActivityWhereInput { + if (actor.activitiesAccess !== undefined) { + return activitiesDeepWhere(actor as Actor); + } const ids = scopedOwnerIds(actor, 'activities'); if (!ids) return {}; return { @@ -150,10 +184,13 @@ export function activityAccessWhere( }; } -/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */ +/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */ export function noteAccessWhere( - actor: ScopedActor, + actor: ScopedActor & Pick, ): Prisma.NoteWhereInput { + if (actor.notesAccess !== undefined) { + return notesDeepWhere(actor as Actor); + } const ids = scopedOwnerIds(actor, 'notes'); const personalGate: Prisma.NoteWhereInput = { OR: [ @@ -195,10 +232,13 @@ export function resolveOwnerId( return actor.userId; } -/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */ +/** تیکت: عمق داده یا مدل قدیمی */ export function ticketAccessWhere( - actor: ScopedActor, + actor: ScopedActor & Pick, ): Prisma.TicketWhereInput { + if (actor.ticketsAccess !== undefined) { + return ticketsDeepWhere(actor as Actor); + } const ids = scopedOwnerIds(actor, 'tickets'); if (!ids) return {}; return { @@ -206,6 +246,14 @@ export function ticketAccessWhere( }; } +/** کار روتین: عمق داده (وقتی JWT ست کرده) */ +export function routineTaskAccessWhere( + actor: Pick, +): Prisma.RoutineTaskWhereInput { + if (actor.routinesAccess === undefined) return {}; + return routinesDeepWhere(actor); +} + export function canReassignOwner(role: string | null | undefined): boolean { return elevated(role); } diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index dbbabbe..b095001 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -16,11 +16,11 @@ import { asCustomFields, toJsonCustomFields, } from '../common/custom-fields'; +import { customersCompanyWhere } from '../common/data-access'; import { type Actor, canReassignOwner, elevated, - ownerWhere, resolveOwnerId, } from '../common/record-scope'; import { normalizeTags } from '../common/tags'; @@ -112,7 +112,7 @@ export class CompaniesService { ]; const where: Prisma.CompanyWhereInput = { organizationId: actor.organizationId, - ...ownerWhere(actor), + ...customersCompanyWhere(actor), ...(and.length ? { AND: and } : {}), }; @@ -137,7 +137,7 @@ export class CompaniesService { async get(actor: Actor, id: string): Promise { const row = await this.prisma.company.findFirst({ - where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, + where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) }, include: companyInclude, }); if (!row) throw new NotFoundException('شرکت پیدا نشد'); diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 5a2670b..1b85348 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm import type { ImportContactsResult, PersonMood } from '@mento/shared'; import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { customersCompanyWhere } from '../common/data-access'; import type { Actor } from '../common/record-scope'; -import { ownerWhere, resolveOwnerId } from '../common/record-scope'; +import { resolveOwnerId } from '../common/record-scope'; import { collectAddresses, } from '../common/custom-fields'; @@ -776,7 +777,7 @@ export class ContactsExcelService { where: { organizationId: actor.organizationId, sourceRef: didarId, - ...ownerWhere(actor), + ...customersCompanyWhere(actor), }, select: { id: true, customFields: true }, }); @@ -787,7 +788,7 @@ export class ContactsExcelService { organizationId: actor.organizationId, name: { equals: name, mode: 'insensitive' }, ...(didarId ? { sourceRef: null } : {}), - ...ownerWhere(actor), + ...customersCompanyWhere(actor), }, select: { id: true, customFields: true }, }); @@ -805,7 +806,7 @@ export class ContactsExcelService { where: { organizationId: actor.organizationId, sourceRef: ref, - ...ownerWhere(actor), + ...customersCompanyWhere(actor), }, select: { id: true }, }); @@ -815,7 +816,7 @@ export class ContactsExcelService { where: { organizationId: actor.organizationId, name: { equals: name, mode: 'insensitive' }, - ...ownerWhere(actor), + ...customersCompanyWhere(actor), }, select: { id: true, sourceRef: true }, }); diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 151192c..7adce20 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -29,10 +29,10 @@ import { toJsonAddresses, toJsonCustomFields, } from '../common/custom-fields'; +import { customersPersonWhere } from '../common/data-access'; import { type Actor, canReassignOwner, - ownerWhere, resolveOwnerId, } from '../common/record-scope'; import { LoyaltyService } from '../loyalty/loyalty.service'; @@ -206,7 +206,7 @@ export class PeopleService implements OnModuleInit { if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); return { organizationId: actor.organizationId, - ...ownerWhere(actor), + ...customersPersonWhere(actor), ...(moods.length ? { mood: { in: moods } } : {}), ...(sources.length ? { importSource: { in: sources } } : {}), ...(and.length ? { AND: and } : {}), @@ -290,7 +290,7 @@ export class PeopleService implements OnModuleInit { by: ['importSource'], where: { organizationId: actor.organizationId, - ...ownerWhere(actor), + ...customersPersonWhere(actor), }, _count: { _all: true }, }); @@ -370,7 +370,7 @@ export class PeopleService implements OnModuleInit { async get(actor: Actor, id: string): Promise { const row = await this.prisma.person.findFirst({ - where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, + where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) }, include: personInclude, }); if (!row) throw new NotFoundException('شخص پیدا نشد'); @@ -538,7 +538,7 @@ export class PeopleService implements OnModuleInit { where: { id: { in: unique }, organizationId: actor.organizationId, - ...ownerWhere(actor), + ...customersPersonWhere(actor), }, }); return { deleted: res.count }; @@ -546,7 +546,7 @@ export class PeopleService implements OnModuleInit { private async ensureExists(actor: Actor, id: string) { const found = await this.prisma.person.findFirst({ - where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, + where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) }, select: { id: true }, }); if (!found) throw new NotFoundException('شخص پیدا نشد'); diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 6752353..a069263 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -24,6 +24,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { renderSmsText } from '../common/sms-text'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; +import { leadsDeepWhere } from '../common/data-access'; import { type Actor, elevated, @@ -59,18 +60,21 @@ function mobileKey(raw: string | null | undefined): string | null { return d.startsWith('0') ? d : d; } -function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { - const actor = forScope(baseActor, 'leads'); - if (elevated(actor.role)) return {}; - if (actor.dataVisibility === 'org') return {}; - if ( - actor.dataVisibility === 'team' && - actor.visibleUserIds && - actor.visibleUserIds.length - ) { - return { assigneeId: { in: actor.visibleUserIds } }; +function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput { + if (actor.leadsAccess !== undefined) { + return leadsDeepWhere(actor); } - return { assigneeId: actor.userId }; + const scoped = forScope(actor, 'leads'); + if (elevated(scoped.role)) return {}; + if (scoped.dataVisibility === 'org') return {}; + if ( + scoped.dataVisibility === 'team' && + scoped.visibleUserIds && + scoped.visibleUserIds.length + ) { + return { assigneeId: { in: scoped.visibleUserIds } }; + } + return { assigneeId: scoped.userId }; } const leadInclude = { diff --git a/apps/api/src/memberships/memberships.service.ts b/apps/api/src/memberships/memberships.service.ts index 5767e16..1115cb6 100644 --- a/apps/api/src/memberships/memberships.service.ts +++ b/apps/api/src/memberships/memberships.service.ts @@ -98,7 +98,7 @@ export class MembershipsService { where: { organizationId, userId }, include: { user: true, - orgRole: { select: { id: true, name: true, color: true } }, + orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } }, }, }); } @@ -108,7 +108,7 @@ export class MembershipsService { where: { organizationId }, include: { user: true, - orgRole: { select: { id: true, name: true, color: true } }, + orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } }, }, orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }], }); diff --git a/apps/api/src/org-roles/dto.ts b/apps/api/src/org-roles/dto.ts index 2561f38..f7a9292 100644 --- a/apps/api/src/org-roles/dto.ts +++ b/apps/api/src/org-roles/dto.ts @@ -3,6 +3,7 @@ import { ArrayUnique, IsArray, IsIn, + IsObject, IsOptional, IsString, MaxLength, @@ -36,6 +37,10 @@ export class CreateOrgRoleDto { @IsIn(PERMISSION_KEYS, { each: true }) permissions?: RolePermissionKey[]; + @IsOptional() + @IsObject() + dataAccessPolicy?: Record; + @IsOptional() @ValidateIf((_, v) => v !== null) @IsString() @@ -64,6 +69,10 @@ export class UpdateOrgRoleDto { @IsIn(PERMISSION_KEYS, { each: true }) permissions?: RolePermissionKey[]; + @IsOptional() + @IsObject() + dataAccessPolicy?: Record; + @IsOptional() @ValidateIf((_, v) => v !== null) @IsString() diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts index 27482b3..03d53d7 100644 --- a/apps/api/src/org-roles/org-roles.service.ts +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -5,7 +5,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared'; +import type { + DataAccessPolicy, + OrgRoleDto, + RolePermissionKey, + UserRole, +} from '@mento/shared'; +import { parseDataAccessPolicy } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -25,6 +31,7 @@ type RoleRow = { description: string; color: string; permissions: string[]; + dataAccessPolicy?: unknown; systemKey: string | null; parentId: string | null; createdAt: Date; @@ -70,6 +77,7 @@ export class OrgRolesService { description?: string; color?: string; permissions?: RolePermissionKey[]; + dataAccessPolicy?: DataAccessPolicy; parentId?: string | null; }, ): Promise { @@ -101,6 +109,9 @@ export class OrgRolesService { color: dto.color ?? parent?.color ?? 'teal', permissions, parentId, + ...(dto.dataAccessPolicy + ? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) } + : {}), }, include: ROLE_MEMBERS_INCLUDE, }); @@ -122,6 +133,7 @@ export class OrgRolesService { description?: string; color?: string; permissions?: RolePermissionKey[]; + dataAccessPolicy?: DataAccessPolicy; parentId?: string | null; }, ): Promise { @@ -144,6 +156,9 @@ export class OrgRolesService { if (dto.permissions !== undefined) { data.permissions = sanitizePermissions(dto.permissions); } + if (dto.dataAccessPolicy !== undefined) { + data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy); + } if (dto.parentId !== undefined) { const nextParentId = @@ -410,6 +425,7 @@ export class OrgRolesService { description: row.description, color: row.color, permissions: sanitizePermissions(row.permissions), + dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy), systemKey, parentId: row.parentId, parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, diff --git a/apps/api/src/project-boards/project-boards.service.ts b/apps/api/src/project-boards/project-boards.service.ts index 22b697e..503ffc2 100644 --- a/apps/api/src/project-boards/project-boards.service.ts +++ b/apps/api/src/project-boards/project-boards.service.ts @@ -35,7 +35,13 @@ const cardInclude = { type CardActor = Pick< Actor, - 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' + | 'userId' + | 'role' + | 'dataVisibility' + | 'visibleUserIds' + | 'scopes' + | 'projectsAccess' + | 'teamPeerIds' >; @Injectable() diff --git a/apps/api/src/routines/routine-scope.ts b/apps/api/src/routines/routine-scope.ts index e77059f..ad7edc3 100644 --- a/apps/api/src/routines/routine-scope.ts +++ b/apps/api/src/routines/routine-scope.ts @@ -1,4 +1,6 @@ import { ForbiddenException } from '@nestjs/common'; +import { sectionHasDataAccessLimits } from '@mento/shared'; +import { sectionOwnerIdUnion } from '../common/data-access'; import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope'; import type { PrismaService } from '../prisma/prisma.service'; @@ -38,8 +40,26 @@ export async function resolveRoutineScope( manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])]; } - const visible = scopedOwnerIds(actor, 'routines'); - const viewable = visible === null ? null : [...new Set([...visible, ...manageable])]; + let viewable: string[] | null; + if (actor.routinesAccess !== undefined) { + if (!sectionHasDataAccessLimits(actor.routinesAccess)) { + viewable = null; + } else { + const owners = sectionOwnerIdUnion(actor.routinesAccess, { + userId: actor.userId, + teamPeerIds: actor.teamPeerIds ?? null, + }); + // فقط-مالک: محدود به owners؛ اگر قانون بدون owners (مثلاً دسته) باشد null = همه + فیلتر deepWhere + viewable = owners === null ? null : owners.length ? owners : [actor.userId]; + } + } else { + const visible = scopedOwnerIds(actor, 'routines'); + viewable = visible === null ? null : [...new Set([...visible, ...manageable])]; + } + + if (viewable !== null) { + viewable = [...new Set([...viewable, ...manageable])]; + } return { ...base, diff --git a/apps/api/src/routines/routine-tasks.service.ts b/apps/api/src/routines/routine-tasks.service.ts index 103bdb1..57db1cc 100644 --- a/apps/api/src/routines/routine-tasks.service.ts +++ b/apps/api/src/routines/routine-tasks.service.ts @@ -16,7 +16,7 @@ import type { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; -import type { Actor } from '../common/record-scope'; +import { type Actor, routineTaskAccessWhere } from '../common/record-scope'; import type { CreateRoutineTaskDto, RoutineEntityQueryDto, @@ -63,6 +63,10 @@ export class RoutineTasksService { return resolveRoutineScope(this.prisma, actor); } + private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput { + return routineTaskAccessWhere(actor); + } + /* ───────── خواندن ───────── */ async today(actor: Actor, userId?: string, date?: string): Promise { @@ -71,6 +75,7 @@ export class RoutineTasksService { if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید'); const day = date && isDayKey(date) ? date : tehranDayKey(); const dayDate = dayKeyToDate(day); + const deep = this.deepWhere(actor); const [todayRows, overdueRows, score] = await Promise.all([ this.prisma.routineTask.findMany({ @@ -79,6 +84,7 @@ export class RoutineTasksService { assigneeId: target, scheduledFor: dayDate, status: { not: 'cancelled' }, + ...deep, }, include: routineTaskInclude, orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }], @@ -89,6 +95,7 @@ export class RoutineTasksService { assigneeId: target, status: 'overdue', scheduledFor: { lt: dayDate }, + ...deep, }, include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } }, orderBy: [{ dueAt: 'asc' }], @@ -148,7 +155,12 @@ export class RoutineTasksService { const filter = userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId); const rows = await this.prisma.routineTask.findMany({ - where: { organizationId: actor.organizationId, status: 'overdue', ...filter }, + where: { + organizationId: actor.organizationId, + status: 'overdue', + ...filter, + ...this.deepWhere(actor), + }, include: routineTaskInclude, orderBy: [{ dueAt: 'asc' }], take: 300, @@ -166,6 +178,7 @@ export class RoutineTasksService { organizationId: actor.organizationId, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, ...filter, + ...this.deepWhere(actor), ...(q.categoryId ? { categoryId: q.categoryId } : {}), ...(q.status ? { status: q.status } : {}), ...(q.source ? { source: q.source } : {}), @@ -196,6 +209,7 @@ export class RoutineTasksService { OR: or, status: { not: 'cancelled' }, ...assigneeFilter(scope), + ...this.deepWhere(actor), }, include: routineTaskInclude, orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }], @@ -208,6 +222,14 @@ export class RoutineTasksService { const scope = await this.scope(actor); const row = await this.findTask(actor.organizationId, id); if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید'); + const deep = this.deepWhere(actor); + if (Object.keys(deep).length) { + const allowed = await this.prisma.routineTask.findFirst({ + where: { id, organizationId: actor.organizationId, ...deep }, + select: { id: true }, + }); + if (!allowed) throw new ForbiddenException('دسترسی ندارید'); + } const events = await this.prisma.routineTaskEvent.findMany({ where: { taskId: id }, include: { actor: { select: { id: true, fullName: true } } }, diff --git a/apps/api/src/sms-campaigns/sms-campaigns.service.ts b/apps/api/src/sms-campaigns/sms-campaigns.service.ts index 75a6f5d..28ee921 100644 --- a/apps/api/src/sms-campaigns/sms-campaigns.service.ts +++ b/apps/api/src/sms-campaigns/sms-campaigns.service.ts @@ -17,6 +17,7 @@ import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { renderSmsText } from '../common/sms-text'; +import { leadsDeepWhere } from '../common/data-access'; import { type Actor, elevated, @@ -28,6 +29,9 @@ import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service' import type { Prisma } from '@prisma/client'; function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput { + if (baseActor.leadsAccess !== undefined) { + return leadsDeepWhere(baseActor); + } const actor = forScope(baseActor, 'leads'); if (elevated(actor.role)) return {}; if (actor.dataVisibility === 'org') return {}; diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 6f98177..5be1f1c 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -112,3 +112,14 @@ export class UpdateUserVisibilityDto { @IsObject() scopes?: Record; } + +export class UpdateUserDataAccessDto { + /** + * null = پاک کردن override و ارث از نقش. + * آبجکت = جایگزینی کامل override. + */ + @ValidateIf((_, v) => v !== null) + @IsOptional() + @IsObject() + dataAccessOverride?: Record | null; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 39cff28..9d7943c 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -20,6 +20,7 @@ import { UpdateLogoutTimeDto, UpdateUserRoleDto, UpdateUserStatusDto, + UpdateUserDataAccessDto, UpdateUserVisibilityDto, } from './dto'; import { UsersService } from './users.service'; @@ -143,6 +144,21 @@ export class UsersController { ); } + @Patch(':id/data-access') + @Roles('owner', 'admin') + updateDataAccess( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateUserDataAccessDto, + ) { + return this.usersService.updateDataAccess( + user.organizationId, + user.role, + id, + dto, + ); + } + @Patch(':id/role') @Roles('owner', 'admin') updateRole( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 7d65969..24bb95d 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -14,7 +14,8 @@ import type { UpdateUserVisibilityRequest, UserRole, } from '@mento/shared'; -import { DATA_SCOPES, DATA_VISIBILITIES } from '@mento/shared'; +import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared'; +import { resolveAllSectionAccess } from '../common/data-access'; import { parseVisibilityScopes } from '../common/record-scope'; import * as bcrypt from 'bcryptjs'; import { Prisma } from '@prisma/client'; @@ -44,11 +45,12 @@ type MembershipRow = { commissionPercent: { toString(): string } | number | null; dataVisibility?: string | null; dataVisibilityScopes?: unknown; + dataAccessOverride?: unknown; priorArchiveEnabled?: boolean; logoutTime?: string | null; blockLoginAfterLogout?: boolean; createdAt: Date; - orgRole: { id: string; name: string; color: string } | null; + orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null; user: { id: string; email: string; @@ -360,6 +362,31 @@ export class UsersService { return this.dtoOf(organizationId, targetId); } + async updateDataAccess( + organizationId: string, + actorRole: UserRole, + targetId: string, + dto: { dataAccessOverride?: Record | null }, + ): Promise { + if (actorRole !== 'owner' && actorRole !== 'admin') { + throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید'); + } + if (dto.dataAccessOverride === 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); + await this.prisma.membership.update({ + where: { id: target.id }, + data: { dataAccessOverride: next }, + }); + return this.dtoOf(organizationId, targetId); + } + async updateRole( organizationId: string, actor: { userId: string; role: UserRole }, @@ -541,6 +568,23 @@ export class UsersService { row.role === 'owner' || row.role === 'admin' ? {} : parseVisibilityScopes(row.dataVisibilityScopes), + dataAccessOverride: + row.role === 'owner' || row.role === 'admin' + ? {} + : parseDataAccessPolicy(row.dataAccessOverride), + ...(() => { + const effectiveDataAccess = resolveAllSectionAccess({ + role: row.role, + rolePolicy: row.orgRole?.dataAccessPolicy, + override: row.dataAccessOverride, + legacyVisibility: row.dataVisibility, + legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes), + }); + return { + effectiveDataAccess, + effectiveCustomersAccess: effectiveDataAccess.customers, + }; + })(), priorArchiveEnabled: Boolean(row.priorArchiveEnabled), logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null, blockLoginAfterLogout: diff --git a/apps/web/src/components/DataAccessEditor.vue b/apps/web/src/components/DataAccessEditor.vue new file mode 100644 index 0000000..58b0bc6 --- /dev/null +++ b/apps/web/src/components/DataAccessEditor.vue @@ -0,0 +1,542 @@ + + + + + diff --git a/apps/web/src/components/DataAccessPolicyPanel.vue b/apps/web/src/components/DataAccessPolicyPanel.vue new file mode 100644 index 0000000..4d702c8 --- /dev/null +++ b/apps/web/src/components/DataAccessPolicyPanel.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index b1d84db..c862a4c 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -5,21 +5,21 @@ import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import type { + DataAccessPolicy, OrganizationDto, OrgUserDto, TeamDto, TeamMemberRole, UpdatePriorArchiveCutoffRequest, - UpdateUserVisibilityRequest, } from '@mento/shared'; import { - DATA_SCOPE_LABELS, - DATA_SCOPES, - DATA_VISIBILITY_LABELS, - DATA_VISIBILITIES, - type DataVisibility, + DATA_ACCESS_SECTION_LABELS, + DATA_ACCESS_SECTIONS, + emptySectionDataAccess, + sectionHasDataAccessLimits, } from '@mento/shared'; import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui'; +import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali'; const props = defineProps<{ @@ -96,7 +96,6 @@ async function setPriorArchive(userId: string, enabled: boolean) { function cutoffPayload(value: string): UpdatePriorArchiveCutoffRequest { const parsed = parseLocalInput(value); if (!parsed) return { cutoffAt: null }; - // پایان همان روز محلی تا ایمپورت همان روز هم قبل از تاریخ قطع شمرده شود parsed.setHours(23, 59, 59, 999); return { cutoffAt: parsed.toISOString() }; } @@ -280,25 +279,75 @@ async function removeTeam(id: string) { } } -const expandedVisibility = ref(null); +const expandedAccess = ref(null); +const draftAccess = ref({}); +const accessMode = ref<'inherit' | 'custom'>('inherit'); +const accessSaving = ref(false); -function scopeOverrides(u: OrgUserDto): number { - return Object.keys(u.dataVisibilityScopes ?? {}).length; +function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy { + const src = policy ?? {}; + const out: DataAccessPolicy = {}; + for (const [key, section] of Object.entries(src)) { + if (!section) continue; + out[key as keyof DataAccessPolicy] = { + mode: section.mode, + rules: (section.rules ?? []).map((r) => ({ ...r })), + }; + } + return out; } -async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) { +function accessSummary(u: OrgUserDto): string { + const override = u.dataAccessOverride ?? {}; + const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s])); + const effective = u.effectiveDataAccess ?? {}; + const limited = DATA_ACCESS_SECTIONS.filter((s) => + sectionHasDataAccessLimits(effective[s] ?? emptySectionDataAccess()), + ); + if (!hasOverride && !limited.length) return 'مثل نقش · بدون محدودیت'; + if (!hasOverride) { + return `مثل نقش · ${toFaDigits(limited.length)} بخش محدود`; + } + const labels = limited.map((s) => DATA_ACCESS_SECTION_LABELS[s]).slice(0, 2); + return `سفارشی · ${labels.join('، ') || 'بدون محدودیت'}${limited.length > 2 ? '…' : ''}`; +} + +function openAccessEditor(u: OrgUserDto) { + if (expandedAccess.value === u.id) { + expandedAccess.value = null; + return; + } + const override = u.dataAccessOverride ?? {}; + const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s])); + accessMode.value = hasOverride ? 'custom' : 'inherit'; + draftAccess.value = hasOverride + ? clonePolicy(override) + : clonePolicy(u.effectiveDataAccess); + expandedAccess.value = u.id; +} + +async function saveUserAccess(userId: string) { + accessSaving.value = true; error.value = ''; + ok.value = ''; try { - await api.patch(`/users/${userId}/visibility`, payload); - ok.value = 'سطح دسترسی به پرونده‌ها به‌روز شد.'; + const payload = + accessMode.value === 'inherit' + ? { dataAccessOverride: null } + : { dataAccessOverride: draftAccess.value }; + await api.patch(`/users/${userId}/data-access`, payload); + ok.value = 'محدوده داده به‌روز شد.'; + expandedAccess.value = null; emit('refreshUsers'); } catch (e) { error.value = axios.isAxiosError(e) ? String( (e.response?.data as { message?: string })?.message ?? - 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.', + 'ذخیره محدوده داده ناموفق بود.', ) - : 'تغییر سطح دسترسی به پرونده‌ها ناموفق بود.'; + : 'ذخیره محدوده داده ناموفق بود.'; + } finally { + accessSaving.value = false; } } @@ -448,70 +497,45 @@ onMounted(() => {
    -

    سطح دسترسی به پرونده‌ها

    +

    محدوده داده (مشتریان / لید / معامله / فعالیت)

    - مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو، - یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛ - مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش. + مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید override + بگذارید.

    {{ u.fullName }} - - {{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی - + {{ accessSummary(u) }} - - - {{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }} + + {{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
    -
    -
    @@ -642,7 +666,7 @@ onMounted(() => { } .vis-row { display: grid; - grid-template-columns: 1fr 240px auto; + grid-template-columns: 1fr auto; gap: 0.75rem; align-items: center; padding: 0.45rem 0; @@ -657,21 +681,16 @@ onMounted(() => { } .vis-scopes { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 0.5rem 1rem; + gap: 0.55rem; padding: 0.6rem 0.75rem; background: #f8fafc; border-radius: 10px; } -.vis-scope { - display: grid; - gap: 0.25rem; - font-size: 0.85rem; - color: #334155; -} -select.custom { - border-color: #6366f1; - background: #eef2ff; +.chip-row { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.88rem; } select { font: inherit; diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 783e1e7..d818329 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -12,6 +12,7 @@ import UiIcon from '@/components/UiIcon.vue'; import { UiDropdownMenu, UiModal } from '@/components/ui'; import { ROLE_PERMISSION_ITEMS, + type DataAccessPolicy, type InviteUserRequest, type OrganizationDto, type OrgModuleKey, @@ -30,6 +31,7 @@ import { toFaDigits, } from '@/lib/jalali'; import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue'; +import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; type TabKey = 'users' | 'roles' | 'access' | 'teams'; type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink'; @@ -89,6 +91,8 @@ const orgName = ref(''); const businessField = ref(''); const orgSaving = ref(false); const accessSaving = ref(false); +const roleFormDataAccess = ref({}); +const focusDataAccess = ref({}); const tab = ref('users'); const query = ref(''); @@ -583,6 +587,7 @@ function openCreateRole(parentId?: string | null) { roleFormPerms.value = parent ? [...parent.permissions] : visibleRolePermissions.value.map((p) => p.key); + roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy); roleFormOpen.value = true; } @@ -602,6 +607,7 @@ function openEditRole() { : 'teal') as RoleColor; roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? ''); roleFormPerms.value = [...role.permissions]; + roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy); roleFormOpen.value = true; } @@ -631,12 +637,14 @@ async function submitRoleForm() { description: string; color: RoleColor; permissions: RolePermissionKey[]; + dataAccessPolicy?: OrgRoleDto['dataAccessPolicy']; parentId?: string | null; } = { name: roleFormName.value.trim(), description: roleFormDesc.value.trim(), color: roleFormColor.value, permissions: roleFormPerms.value, + dataAccessPolicy: roleFormDataAccess.value, }; if (!editing?.systemKey) { body.parentId = roleFormParentId.value || null; @@ -693,6 +701,40 @@ async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) { } } +function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy { + const src = policy ?? {}; + const out: DataAccessPolicy = {}; + for (const [key, section] of Object.entries(src)) { + if (!section) continue; + out[key as keyof DataAccessPolicy] = { + mode: section.mode, + rules: (section.rules ?? []).map((r) => ({ ...r })), + }; + } + return out; +} + +function syncFocusDataAccess() { + focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy); +} + +async function saveFocusDataAccess() { + const role = accessFocusRole.value; + if (!role || !canEditRoles.value) return; + accessSaving.value = true; + error.value = ''; + try { + await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy: focusDataAccess.value }); + ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`; + await load(); + syncFocusDataAccess(); + } catch (e) { + error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود'); + } finally { + accessSaving.value = false; + } +} + function onDocClick() { menuUserId.value = null; } @@ -728,10 +770,15 @@ watch( if (!list.some((r) => r.id === accessFocusId.value)) { accessFocusId.value = list[0].id; } + syncFocusDataAccess(); }, { immediate: true }, ); +watch(accessFocusId, () => { + syncFocusDataAccess(); +}); + onMounted(() => { syncViewport(); window.addEventListener('resize', syncViewport); @@ -862,6 +909,29 @@ onUnmounted(() => {
    + +
    +
    +

    محدوده داده — {{ accessFocusRole.name }}

    + +
    +

    + زیرمجموعهٔ مجوز هر بخش: اگر تنظیم نشود همه را می‌بیند؛ قوانین با هم «یا» می‌شوند. +

    + +
    @@ -1362,6 +1432,14 @@ onUnmounted(() => { {{ perm.label }} +
    + محدوده داده + +