19 Commits
Author SHA1 Message Date
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
MahdiandCursor 1ef7f91771 fix(contacts): keep one customer when Tahesab repeats a mobile
A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:34:41 +03:30
MahdiandCursor 94f0383c79 feat(tahesab): import and refresh customers at chosen hours
Each connection can set how many times a day and at which Tehran hours the server pulls Tahesab customers, including name updates for people already linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:31:27 +03:30
MahdiandCursor c976c0bcd6 fix(contacts): merge the same mobile stored on phone or mobile
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:27:05 +03:30
MahdiandCursor 57a0b379f6 fix(tahesab): store a matched mobile as 09xxxxxxxxx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:11:40 +03:30
MahdiandCursor 7f6d9a84d6 fix(contacts): treat Iranian mobile formats as one person
Tahesab import still replaces the name and keeps the previous name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:51:36 +03:30
MahdiandCursor 6abbf8e8fd fix(sms): keep the charge dialog fully on screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:52:35 +03:30
MahdiandCursor 39cb5c2548 feat(public-api): let external sites register a lead with their key
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:34:07 +03:30
MahdiandCursor 004d1dff93 feat(boards): ease dragging a card toward an off-screen spot
Scroll the column while a card is held near its edge and show a light gap where the card will land.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 20:05:50 +03:30
MahdiandCursor 78a7d6a68a feat(routines): let each person create a routine for themselves
Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 19:34:36 +03:30
MahdiandCursor 5b18749005 feat(treasury): list debtors and creditors on the finance report
Show open receivable and payable balances on separate report tabs, and include each list in an Excel export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:16:38 +03:30
MahdiandCursor 08a278e1c8 feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:11:09 +03:30
MahdiandCursor d71c2dbef4 feat(treasury): show who still owes on the finance pages
Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:36:55 +03:30
MahdiandCursor 1ccbefa7a3 fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:02:31 +03:30
MahdiandCursor 14f4e9d7d8 fix(activities): put the priority flag on the date row and recolor medium and low
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:32:49 +03:30
MahdiandCursor 52edccf74e fix(boards): keep card order inside a column and show activity priority
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:22:30 +03:30
MahdiandCursor bffe174162 fix(activities): show form photos in the note instead of the raw file link
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:30:45 +03:30
MahdiandCursor 35d74d33ed fix(activities): keep lead activity actions in the detail card
The action row stretched off-screen and comments on automation activities were rejected once the saved note passed 2000 characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:04:59 +03:30
88 changed files with 4314 additions and 556 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+131
View File
@@ -0,0 +1,131 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
if: false
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
if: false
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
@@ -0,0 +1,3 @@
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1 @@
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
@@ -0,0 +1,9 @@
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
CREATE TABLE "DataFix" (
"key" TEXT NOT NULL,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1,6 @@
ALTER TABLE "AccountingConnection"
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "customerSyncLastSlot" TEXT,
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
ADD COLUMN "customerSyncLastSummary" TEXT,
ADD COLUMN "customerSyncLastError" TEXT;
+32
View File
@@ -295,6 +295,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees")
@@ -353,6 +354,10 @@ model OrgRole {
permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}")
/// حساب‌های مالی اضافی این نقش؛ همراه با حساب‌هایی که خودش مسئول است
treasuryAccountIds String[] @default([])
/// اعضای نقش، حساب‌هایی را ببینند که نام خودشان مسئول آن است
treasuryOwnAccounts Boolean @default(false)
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -387,6 +392,8 @@ model Membership {
dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}")
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخش‌ها و حساب‌های مالی همین کاربر
treasuryAccessOverride Json?
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false)
/// current | archive
@@ -518,6 +525,7 @@ model Company {
customFields Json @default("{}")
groupLinks CompanyGroupLink[]
people Person[]
financialDocuments FinancialDocument[]
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -896,6 +904,8 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
/// ترتیب کارت داخل همان ستون کانبان
boardPosition Int @default(0)
/// نوع سفارشی؛ null → نوع اصلیِ همان type
typeDefId String?
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
@@ -911,6 +921,7 @@ model Activity {
updatedAt DateTime @updatedAt
@@index([stageId])
@@index([stageId, boardPosition])
@@index([boardId])
@@index([typeDefId])
@@index([leadId])
@@ -1832,6 +1843,13 @@ model AccountingConnection {
lastHealthAt DateTime?
lastHealthOk Boolean?
lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[]
@@ -2321,6 +2339,12 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String?
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
userId String?
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
/// person | company | user | other
partyKind String?
partyName String?
accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2341,6 +2365,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt])
@@index([organizationId, status])
@@index([personId])
@@index([companyId])
@@index([userId])
}
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -3760,3 +3786,9 @@ model AttendanceEvent {
@@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt])
}
/// علامت یک‌بار اجرای اصلاح داده
model DataFix {
key String @id
doneAt DateTime @default(now())
}
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id;
};
const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile);
const mobile = storedPhoneNumber(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null;
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile);
if (mobile) {
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true },
});
if (byMobile) return byMobile.id;
@@ -428,7 +428,13 @@ export class ActivitiesController {
@Param('id') id: string,
@Body() dto: MoveActivityStageDto,
) {
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
return this.activitiesService.moveToStage(
user.organizationId,
id,
user,
dto.stageId,
dto.position,
);
}
@Patch(':id/complete')
+39 -4
View File
@@ -31,6 +31,7 @@ import {
ownerWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
@@ -329,9 +330,7 @@ export class ActivitiesService {
where,
include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
orderBy: this.listOrder(entity, stageScope),
skip: (page - 1) * take,
take,
}),
@@ -385,7 +384,7 @@ export class ActivitiesService {
this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
orderBy: this.listOrder(entity, stageScope),
take: cap,
}),
this.prisma.activity.count({ where }),
@@ -490,11 +489,46 @@ export class ActivitiesService {
return { counts, total };
}
private listOrder(
entity?: { leadId?: string },
stageScope?: StageScope,
): Prisma.ActivityOrderByWithRelationInput[] {
if (entity?.leadId) return [{ createdAt: 'desc' }];
if (stageScope && stageScope.stageId !== 'all') {
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
}
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
}
private async reorderStage(
organizationId: string,
stageId: string,
activityId: string,
index: number,
) {
const where = await this.stages.whereForStage(organizationId, stageId);
const rows = await this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
const placed = placeId(currentIds, activityId, index);
if (sameIds(currentIds, placed)) return;
await writePositions(
(ops) => this.prisma.$transaction(ops),
placed,
(id, boardPosition) =>
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
);
}
async moveToStage(
organizationId: string,
id: string,
actor: ActivityActor,
stageId: string,
position?: number,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId);
@@ -519,6 +553,7 @@ export class ActivitiesService {
},
include,
});
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
+8 -2
View File
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
export class MoveActivityStageDto {
@IsString()
stageId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -316,7 +322,7 @@ export class CreateActivityDto {
@IsOptional()
@IsString()
@MaxLength(2000)
@MaxLength(20000)
description?: string;
@IsOptional()
@@ -416,7 +422,7 @@ export class UpdateActivityDto {
@IsOptional()
@IsString()
@MaxLength(2000)
@MaxLength(20000)
description?: string | null;
@IsOptional()
+28
View File
@@ -0,0 +1,28 @@
import type { Prisma } from '@prisma/client';
type PositionUpdate = Prisma.PrismaPromise<unknown>;
/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
export function placeId(ids: string[], movingId: string, index: number): string[] {
const next = ids.filter((id) => id !== movingId);
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
next.splice(at, 0, movingId);
return next;
}
export function sameIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((id, index) => id === right[index]);
}
/** ترتیب ستون را ۰ تا n ذخیره می‌کند. */
export async function writePositions(
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
ids: string[],
update: (id: string, position: number) => PositionUpdate,
) {
const size = 60;
for (let start = 0; start < ids.length; start += size) {
const slice = ids.slice(start, start + size);
await transaction(slice.map((id, offset) => update(id, start + offset)));
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+367
View File
@@ -0,0 +1,367 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+33
View File
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null;
}
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+18 -7
View File
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import {
asAddresses,
asCustomFields,
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date },
) {
const or: Prisma.PersonWhereInput[] = [];
if (input.mobile) or.push({ mobile: input.mobile });
const mobileKeys = phoneIdentityKeys(input.mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (input.email) or.push({ email: input.email });
if (!or.length) return;
await this.prisma.person.updateMany({
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
phone: storedPhoneNumber(dto.phone),
mobile: storedPhoneNumber(dto.mobile),
position: emptyToNull(dto.position),
website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram),
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
}
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
@@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit {
) {
const email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
if (!or.length) return;
const clash = await this.prisma.person.findFirst({
@@ -0,0 +1,85 @@
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { mergeSameMobilePeople } from './merge-same-mobile';
const FIX_KEY = 'person-mobile-canonical-v3';
@Injectable()
export class SameMobileMergeService implements OnApplicationBootstrap {
private readonly logger = new Logger(SameMobileMergeService.name);
constructor(private readonly prisma: PrismaService) {}
onApplicationBootstrap() {
if (process.env.NODE_ENV === 'test') return;
void this.run();
}
private async run() {
try {
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
SELECT 1 AS ok
FROM information_schema.columns
WHERE table_schema = 'public'
AND lower(table_name) = 'person'
AND lower(column_name) = 'gender'
`;
if (!ready.length) {
this.logger.warn('یکسان‌سازی موبایل رد شد: ستون gender هنوز نیست');
return;
}
await this.prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS "DataFix" (
"key" TEXT PRIMARY KEY,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`;
const done = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (done.length) return;
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
try {
const again = await this.prisma.$queryRaw<{ key: string }[]>`
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
`;
if (again.length) return;
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const org of orgs) {
try {
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
merged += result.merged;
rewritten += result.rewritten;
failed += result.failed;
} catch (error) {
failed += 1;
this.logger.error(
`یکسان‌سازی موبایل برای محیط ${org.id} انجام نشد`,
error instanceof Error ? error.message : String(error),
);
}
}
if (failed) return;
await this.prisma.$executeRaw`
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
ON CONFLICT ("key") DO NOTHING
`;
this.logger.log(`شماره‌های تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
} finally {
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
}
} catch (error) {
this.logger.error(
'یکسان‌سازی موبایل مشتری‌ها انجام نشد',
error instanceof Error ? error.stack : String(error),
);
}
}
}
@@ -17,7 +17,11 @@ import type {
} from '@mento/shared';
import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service';
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
}
function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, '');
let core = digits;
if (core.startsWith('0098')) core = core.slice(4);
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
else if (core.startsWith('0')) core = core.slice(1);
if (!core) return [mobile];
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
return phoneIdentityKeys(mobile);
}
function formatAddress(a: MontifyAddress): string | null {
@@ -417,7 +415,7 @@ export class MontifySyncService {
}> {
const externalId = String(c.id);
const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null);
const mobile = storedPhoneNumber(c.phone ?? null);
const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = {
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null;
lastHealthOk: boolean | null;
lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date;
updatedAt: Date;
}) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
customerSyncLastSummary: row.customerSyncLastSummary,
customerSyncLastError: row.customerSyncLastError,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
let customerSyncHours: number[] | undefined;
if (dto.customerSyncHours !== undefined) {
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
if (new Set(hours).size !== hours.length) {
throw new BadRequestException('ساعت‌های بروزرسانی نباید تکراری باشند.');
}
customerSyncHours = [...hours].sort((a, b) => a - b);
}
let enc = {
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
},
});
return this.toDto(updated);
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabPersonLinkService } from './person-link.service';
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
let hour = Number(read('hour'));
if (!Number.isFinite(hour) || hour === 24) hour = 0;
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
return { hour, slot };
}
@Injectable()
export class TahesabCustomerSyncScheduler {
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
private busy = false;
constructor(
private readonly prisma: PrismaService,
private readonly links: TahesabPersonLinkService,
) {}
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
async tick() {
if (process.env.NODE_ENV === 'test' || this.busy) return;
this.busy = true;
try {
const { hour, slot } = tehranSyncSlot();
const connections = await this.prisma.accountingConnection.findMany({
where: {
provider: 'tahesab',
enabled: true,
customerSyncHours: { has: hour },
},
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
});
for (const connection of connections) {
if (connection.customerSyncLastSlot === slot) continue;
const claimed = await this.prisma.accountingConnection.updateMany({
where: {
id: connection.id,
OR: [
{ customerSyncLastSlot: null },
{ customerSyncLastSlot: { not: slot } },
],
},
data: { customerSyncLastSlot: slot },
});
if (claimed.count !== 1) continue;
await this.run(connection.organizationId, connection.id, connection.label);
}
} catch (err) {
this.logger.warn(
`بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${
err instanceof Error ? err.message : err
}`,
);
} finally {
this.busy = false;
}
}
private async run(organizationId: string, connectionId: string, label: string) {
try {
const result = await this.links.syncAllCustomers(organizationId, connectionId);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: result.summary,
customerSyncLastError: result.error,
},
});
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
} catch (err) {
const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: null,
customerSyncLastError: message,
},
});
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
}
}
}
+10
View File
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
}
export class ManualLinkDto {
@@ -8,7 +8,12 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source';
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service';
import {
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
function canonicalMobileUpdate(
incoming: string | null,
current: { mobile: string | null; phone: string | null },
): { mobile?: string; phone?: string } {
const mobile =
canonicalIranMobile(incoming) ??
canonicalIranMobile(current.mobile) ??
canonicalIranMobile(current.phone);
if (!mobile) return {};
const phone = canonicalIranMobile(current.phone);
return phone ? { mobile, phone } : { mobile };
}
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000;
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
organizationId: string,
dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) {
const clash = await this.prisma.personAccountingLink.findFirst({
where: {
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName;
const lastName = named.lastName;
const mobile = tel || null;
const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
let preserveExistingLink = false;
if (personId) {
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
...canonicalMobileUpdate(mobile, existing),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const byMobile = mobile
? await this.prisma.person.findFirst({
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
accountingLinks: { none: { connectionId: dto.connectionId } },
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
include: {
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
orderBy: { createdAt: 'asc' },
})
: null;
: [];
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef,
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
},
});
personId = byMobile.id;
@@ -669,6 +696,32 @@ export class TahesabPersonLinkService {
}
}
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
});
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
personId: string,
moshtariCode: number,
row: Record<string, unknown>,
) {
const existing = await this.getPerson(organizationId, personId);
const picked = pickName(row).trim();
const named = picked
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
: null;
const nameChanged =
named != null &&
namesDiffer(
existing.firstName,
existing.lastName,
named.firstName,
named.lastName,
);
await this.prisma.person.update({
where: { id: personId },
data: {
...(named
? {
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
...(nameChanged
? {
previousFirstName: existing.firstName || null,
previousLastName: existing.lastName || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
}
/**
* همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند.
* کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود.
*/
async syncAllCustomers(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
let page = 1;
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
while (emptyStreak < 2 && page <= maxPages) {
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
try {
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
} catch (err) {
const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود';
errors.push(message);
break;
}
if (!listed.rows.length) {
emptyStreak += 1;
page += 1;
continue;
}
emptyStreak = 0;
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null && code >= 1) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: {
connectionId,
moshtariCode: { in: [...rowsByCode.keys()] },
},
select: { moshtariCode: true, personId: true },
})
: [];
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
for (const [code, row] of rowsByCode) {
try {
const personId = linkedByCode.get(code);
if (personId) {
await this.refreshLinkedCustomer(
organizationId,
connectionId,
personId,
code,
row,
);
updated += 1;
} else {
await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
imported += 1;
}
} catch (err) {
failed += 1;
if (errors.length < 5) {
errors.push(err instanceof Error ? err.message : 'خطا');
}
}
}
page += 1;
}
if (page > maxPages && emptyStreak < 2) {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
return { personId, link: this.linkDto(link) };
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
],
exports: [
TahesabCryptoService,
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service';
import { normalizePhone } from '../../contacts/normalize';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string {
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`);
variants.add(`98${d}`);
}
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants];
}
@@ -6,7 +6,11 @@ import type {
WooCommerceSyncResultDto,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import {
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client';
@@ -374,7 +378,8 @@ export class WooSyncService {
): Promise<string | null> {
const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null);
const mobile = normalizePhone(billing.phone ?? null);
const mobile = storedPhoneNumber(billing.phone ?? null);
const mobileKeys = phoneIdentityKeys(mobile);
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -387,7 +392,9 @@ export class WooSyncService {
organizationId,
OR: [
...(email ? [{ email }] : []),
...(mobile ? [{ mobile }, { phone: mobile }] : []),
...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
],
},
select: { id: true },
+16 -8
View File
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
canonicalIranMobile,
emptyToNull,
normalizeEmail,
normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service';
@@ -458,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({
where: {
organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }],
OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
},
select: { id: true, firstName: true, lastName: true },
})
@@ -641,13 +647,16 @@ export class LeadFormsService {
ownerId: string,
): Promise<string> {
const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile);
const phone = normalizePhone(mapped.phone);
const mobile = storedPhoneNumber(mapped.mobile);
const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
const mobileKeys = phoneIdentityKeys(mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
const existing = or.length
? await this.prisma.person.findFirst({
where: { organizationId, OR: or },
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
}
function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw);
if (!n) return null;
const d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
return d || null;
}
function normalizeDuplicateMode(raw?: string | null): string {
+140 -28
View File
@@ -32,12 +32,18 @@ import {
forScope,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import {
asAddresses,
collectAddresses,
toJsonAddresses,
} from '../common/custom-fields';
import { emptyToNull, normalizePhone } from '../contacts/normalize';
import {
canonicalIranMobile,
emptyToNull,
normalizePhone,
phoneIdentityKeys,
} from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service';
@@ -54,13 +60,20 @@ const LOGO_EXT: Record<string, string> = {
};
function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw);
if (!n) return null;
const d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
return d || null;
}
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
const keys = phoneIdentityKeys(mobile);
return {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
};
}
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -322,10 +335,7 @@ export class LeadsService {
};
}
const person = await this.prisma.person.findFirst({
where: {
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
where: personWithMobile(actor.organizationId, mobile),
select: { id: true, firstName: true, lastName: true },
});
if (person) {
@@ -613,24 +623,43 @@ export class LeadsService {
});
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
let placedIds: string[] | null = null;
let nextPosition = position ?? 0;
if (position != null) {
const rows = await this.prisma.lead.findMany({
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, id, position);
nextPosition = Math.max(0, placedIds.indexOf(id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.lead.update({
where: { id },
data: {
stageId,
position: position ?? 0,
...(stage.isConverted
? {}
: {}),
position: nextPosition,
},
include: leadInclude,
});
await this.addEvent(
actor,
id,
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
if (placedIds) {
await writePositions(
(ops) => this.prisma.$transaction(ops),
placedIds,
(leadId, leadPosition) =>
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
);
}
if (before.stageId !== stageId) {
await this.addEvent(
actor,
id,
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${stageId}`,
facts: { stageName: stage.name, source: row.source },
@@ -946,10 +975,7 @@ export class LeadsService {
}
let person = await this.prisma.person.findFirst({
where: {
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
where: personWithMobile(actor.organizationId, lead.mobile),
});
let createdPerson = false;
@@ -976,7 +1002,7 @@ export class LeadsService {
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile: lead.mobile,
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone,
instagram: lead.instagram,
birthDate: lead.birthDate,
@@ -1150,6 +1176,95 @@ export class LeadsService {
};
}
async createFromExternal(input: {
organizationId: string;
siteName: string;
firstName?: string | null;
lastName?: string | null;
mobile: string;
storeName?: string | null;
source?: string | null;
}): Promise<{
ok: true;
created: boolean;
duplicate: boolean;
leadId: string | null;
}> {
const mobile = mobileKey(input.mobile);
if (!mobile || !/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const contactName =
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
const storeName = input.storeName?.trim() ?? '';
const source = emptyToNull(input.source);
const title = storeName || contactName || mobile;
const siteName = input.siteName.trim() || 'سایت بیرونی';
const existing = await this.prisma.lead.findFirst({
where: { organizationId: input.organizationId, mobile, archivedAt: null },
select: { id: true, source: true, contactName: true },
});
if (existing) {
const patch: Prisma.LeadUpdateInput = {};
if (!existing.source && source) patch.source = source;
if (!existing.contactName && contactName) patch.contactName = contactName;
if (Object.keys(patch).length) {
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
}
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: existing.id,
type: 'note',
title: 'درخواست دوباره از سایت',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: false, duplicate: true, leadId: existing.id };
}
const person = await this.prisma.person.findFirst({
where: personWithMobile(input.organizationId, mobile),
select: { id: true },
});
if (person) {
return { ok: true, created: false, duplicate: true, leadId: null };
}
await this.stages.ensureDefaults(input.organizationId);
const stages = await this.stages.list(input.organizationId);
const stageId = stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const lead = await this.prisma.lead.create({
data: {
organizationId: input.organizationId,
title,
contactName,
mobile,
source,
notes: `ثبت از سایت: ${siteName}`,
stageId,
},
});
await this.prisma.leadEvent.create({
data: {
organizationId: input.organizationId,
leadId: lead.id,
type: 'created',
title: 'لید از سایت ثبت شد',
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
.filter(Boolean)
.join('\n'),
},
});
return { ok: true, created: true, duplicate: false, leadId: lead.id };
}
async createFromImportRow(
organizationId: string,
createdById: string | null,
@@ -1192,10 +1307,7 @@ export class LeadsService {
}
const existingPerson = await this.prisma.person.findFirst({
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
where: personWithMobile(organizationId, mobile),
select: { id: true },
});
if (existingPerson) {
+21
View File
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsIn,
IsObject,
IsOptional,
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
}
export class UpdateOrgRoleDto {
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
}
export class RolePermissionPatchItemDto {
@@ -32,6 +32,8 @@ type RoleRow = {
color: string;
permissions: string[];
dataAccessPolicy?: unknown;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
systemKey: string | null;
parentId: string | null;
createdAt: Date;
@@ -79,6 +81,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -108,6 +112,8 @@ export class OrgRolesService {
description: (dto.description ?? '').trim(),
color: dto.color ?? parent?.color ?? 'teal',
permissions,
treasuryAccountIds: dto.treasuryAccountIds ?? [],
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
parentId,
...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
@@ -135,6 +141,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -159,6 +167,12 @@ export class OrgRolesService {
if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
}
if (dto.treasuryAccountIds !== undefined) {
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
}
if (dto.treasuryOwnAccounts !== undefined) {
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
}
if (dto.parentId !== undefined) {
const nextParentId =
@@ -426,6 +440,8 @@ export class OrgRolesService {
color: row.color,
permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
treasuryAccountIds: row.treasuryAccountIds ?? [],
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
systemKey,
parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> {
const orgId = actor.organizationId;
const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
const validMobile = mobileCore.length >= 8 ? mobile : null;
const mobile = storedPhoneNumber(row.personMobile);
const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
}
if (!found && validMobile) {
found = await this.prisma.person.findFirst({
where: { organizationId: orgId, mobile: { contains: mobileCore } },
where: {
organizationId: orgId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true },
});
}
+23 -1
View File
@@ -27,6 +27,7 @@ import {
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -379,7 +380,8 @@ export class DealsService {
}
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position =
let placedIds: string[] | null = null;
let position =
dto.position ??
((
await this.prisma.deal.aggregate({
@@ -387,6 +389,17 @@ export class DealsService {
_max: { position: true },
})
)._max.position ?? -1) + 1;
if (dto.position != null) {
const rows = await this.prisma.deal.findMany({
where: { organizationId, stageId: stage.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, deal.id, dto.position);
position = Math.max(0, placedIds.indexOf(deal.id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.deal.update({
where: { id },
@@ -402,6 +415,15 @@ export class DealsService {
include: dealInclude,
});
if (placedIds) {
await writePositions(
(ops) => this.prisma.$transaction(ops),
placedIds,
(dealId, nextPosition) =>
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
);
}
if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({
organizationId,
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string,
): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string,
): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim();
if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() },
});
let person = await this.prisma.person.findFirst({
where: { organizationId: org.id, mobile },
orderBy: { createdAt: 'asc' },
});
let person = mobileKeys.length
? await this.prisma.person.findFirst({
where: {
organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) {
person = await this.prisma.person.create({
data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile);
if (mobile) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } },
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true },
});
if (byMobile) return byMobile.id;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string;
role: string;
apiKeyId?: string;
apiKeyName?: string;
};
}>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId,
role: auth.role,
apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
};
return true;
}
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate(
rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true },
select: { id: true, organizationId: true, name: true },
});
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return {
organizationId: row.organizationId,
apiKeyId: row.id,
name: row.name,
role: 'owner',
};
}
@@ -1,18 +1,55 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
class CreateExternalLeadDto {
@IsOptional()
@IsString()
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(80)
lastName?: string;
@IsString()
@MinLength(8)
@MaxLength(20)
mobile!: string;
@IsOptional()
@IsString()
@MaxLength(200)
storeName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
source?: string;
}
const LEAD_RATE_WINDOW_MS = 60_000;
const LEAD_RATE_MAX = 60;
const leadHits = new Map<string, number[]>();
class ListQueryDto {
@IsOptional()
@IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1')
@UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly leads: LeadsService,
) {}
@Post('leads')
@HttpCode(200)
createLead(
@CurrentUser()
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
@Body() body: CreateExternalLeadDto,
) {
const bucket = user.apiKeyId || user.organizationId;
const now = Date.now();
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
if (prev.length >= LEAD_RATE_MAX) {
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
}
prev.push(now);
leadHits.set(bucket, prev);
return this.leads.createFromExternal({
organizationId: user.organizationId,
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
firstName: body.firstName,
lastName: body.lastName,
mobile: body.mobile,
storeName: body.storeName,
source: body.source,
});
}
@Get('people')
async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Module({
imports: [AuthModule, BillingModule],
imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService],
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.create({
data: {
...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
this.assertCanWriteRoutine(scope, existing);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
this.assertCanWriteRoutine(scope, src);
const {
id: _id,
createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row;
}
/** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
if (scope.isManager) return dto;
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید');
}
return { ...dto, assigneeId: actor.userId, teamId: null };
}
private assertCanWriteRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.isManager) {
this.assertCanManageRoutine(scope, row);
return;
}
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید');
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
+54 -9
View File
@@ -1,7 +1,16 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
parseUserTreasuryAccess,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
/**
* حقوق فقط با treasury_payroll.
* بقیه مسیرها با treasury باز می‌شوند؛ اگر بخش ریز انتخاب شده باشد فقط همان‌ها.
*/
@Injectable()
export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
@@ -10,20 +19,56 @@ export class TreasuryAccessGuard implements CanActivate {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
const url = req.originalUrl || req.url || '';
const payroll = url.includes('/treasury/payroll');
if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } },
select: {
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
});
const permissions = Array.isArray(membership?.orgRole?.permissions)
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const allowed = payroll
? permissions.includes('treasury_payroll')
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
const permissions = permissionsForTreasury(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
const url = req.originalUrl || req.url || '';
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
return true;
}
}
function permissionsForTreasury(
rolePermissions: string[],
override: ReturnType<typeof parseUserTreasuryAccess>,
): string[] {
if (!override) return rolePermissions;
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
if (url.includes('/treasury/payroll')) return 'treasury_payroll';
if (url.includes('/treasury/receivables') || url.includes('/treasury/installments')) {
return 'treasury_receivables';
}
if (url.includes('/treasury/recurring')) return 'treasury_recurring';
if (url.includes('/treasury/reports')) return 'treasury_reports';
if (url.includes('/treasury/audit')) return 'treasury_audit';
if (url.includes('/treasury/categories')) return 'treasury_categories';
if (
url.includes('/treasury/accounts') ||
url.includes('/treasury/transfers') ||
url.includes('/treasury/reconciliations') ||
url.includes('/treasury/adjustments')
) {
return 'treasury_accounts';
}
if (url.includes('/treasury/dashboard')) return 'treasury_dashboard';
return 'treasury_documents';
}
+38 -1
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
}
async reportExcel(actor: Actor, from?: string, to?: string) {
async reportExcel(
actor: Actor,
from?: string,
to?: string,
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
) {
const data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی');
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
if (parties) {
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافت‌شده');
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداخت‌شده');
}
const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer);
}
private addPartySheet(
book: ExcelJS.Workbook,
title: string,
rows: TreasuryPartyBalanceDto[],
settledLabel: string,
) {
const sheet = book.addWorksheet(title);
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
let amount = new Prisma.Decimal(0);
let settled = new Prisma.Decimal(0);
let remaining = new Prisma.Decimal(0);
for (const row of rows) {
sheet.addRow([
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
row.amount,
row.settledAmount,
row.remaining,
]);
amount = amount.plus(row.amount);
settled = settled.plus(row.settledAmount);
remaining = remaining.plus(row.remaining);
}
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
}
private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({
where: {
+55 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; isActive?: boolean },
) {
return this.treasury.updateCategory(user, id, body);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteCategory(user, id);
}
@Get('accounts')
accounts(
@CurrentUser() user: Actor,
@@ -61,7 +75,18 @@ export class TreasuryController {
updateAccount(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
@Body()
body: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
) {
return this.treasury.updateAccount(user, id, body);
}
@@ -81,6 +106,29 @@ export class TreasuryController {
return this.treasury.transfer(user, body);
}
@Get('parties')
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.treasury.searchParties(user, q);
}
@Post('parties/person')
createPartyPerson(
@CurrentUser() user: Actor,
@Body() body: { firstName?: string; lastName?: string },
) {
return this.treasury.createPartyPerson(user, body);
}
@Post('parties/company')
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
return this.treasury.createPartyCompany(user, body);
}
@Get('party-balances')
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
@@ -221,7 +269,11 @@ export class TreasuryController {
@Query('from') from?: string,
@Query('to') to?: string,
) {
const buffer = await this.ops.reportExcel(user, from, to);
const [debtors, creditors] = await Promise.all([
this.treasury.partyBalances(user, 'income'),
this.treasury.partyBalances(user, 'expense'),
]);
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule],
imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController],
providers: [
TreasuryService,
+455 -36
View File
@@ -1,8 +1,10 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
@@ -11,8 +13,14 @@ import type {
FinancialCategoryDto,
FinancialDocumentDto,
FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared';
import { CompaniesService } from '../contacts/companies.service';
import { PeopleService } from '../contacts/people.service';
import { dateToJalali } from '../common/jalali';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -63,6 +71,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async ensureCategories(organizationId: string) {
@@ -110,27 +120,80 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> {
const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
try {
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async updateCategory(
actor: Actor,
id: string,
input: { name?: string; isActive?: boolean },
): Promise<FinancialCategoryDto> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
const name = input.name == null ? current.name : input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.update({
where: { id: current.id },
data: {
name,
...(input.isActive == null ? {} : { isActive: input.isActive }),
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
await this.prisma.financialCategory.delete({ where: { id: current.id } });
return { ok: true };
}
async listAccounts(
actor: Actor,
range?: { from?: string; to?: string },
): Promise<FinancialAccountDto[]> {
const scope = await this.accountScope(actor);
const accounts = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId },
where: {
organizationId: actor.organizationId,
...(scope ? { id: { in: scope } } : {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
});
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
@@ -196,19 +259,89 @@ export class TreasuryService {
async updateAccount(
actor: Actor,
id: string,
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
input: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
): Promise<FinancialAccountDto> {
const existing = await this.requireAccount(actor.organizationId, id);
const row = await this.prisma.financialAccount.update({
where: { id: existing.id },
data: {
...(input.name?.trim() ? { name: input.name.trim() } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
...(input.responsibleName !== undefined
? { responsibleName: input.responsibleName?.trim() || null }
: {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
},
const existing = await this.requireAccount(actor.organizationId, id, actor);
if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
throw new BadRequestException('نوع حساب نامعتبر است.');
}
const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
if (opening?.isNegative()) {
throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.');
}
const openingDate =
input.openingDate !== undefined
? (startOfDay(input.openingDate) ?? existing.openingDate)
: null;
const row = await this.prisma.$transaction(async (tx) => {
const updated = await tx.financialAccount.update({
where: { id: existing.id },
data: {
...(input.name?.trim() ? { name: input.name.trim() } : {}),
...(input.type ? { type: input.type } : {}),
...(input.bankName !== undefined ? { bankName: input.bankName?.trim() || null } : {}),
...(input.accountRef !== undefined ? { accountRef: input.accountRef?.trim() || null } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
...(input.responsibleName !== undefined
? { responsibleName: input.responsibleName?.trim() || null }
: {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
...(opening ? { openingBalance: opening } : {}),
...(openingDate ? { openingDate } : {}),
},
});
if (opening || openingDate) {
const nextAmount = opening ?? existing.openingBalance;
const nextDate = openingDate ?? existing.openingDate;
const openings = await tx.financialMovement.findMany({
where: { accountId: existing.id, kind: 'opening', voidedAt: null },
orderBy: { createdAt: 'asc' },
});
if (nextAmount.isZero()) {
if (openings.length) {
await tx.financialMovement.updateMany({
where: { id: { in: openings.map((row) => row.id) } },
data: { voidedAt: new Date() },
});
}
} else if (openings[0]) {
await tx.financialMovement.update({
where: { id: openings[0].id },
data: { amount: nextAmount, occurredAt: nextDate, direction: 'in' },
});
const extra = openings.slice(1).map((row) => row.id);
if (extra.length) {
await tx.financialMovement.updateMany({
where: { id: { in: extra } },
data: { voidedAt: new Date() },
});
}
} else {
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: existing.id,
direction: 'in',
amount: nextAmount,
kind: 'opening',
occurredAt: nextDate,
memo: 'مانده افتتاحیه',
createdById: actor.userId,
},
});
}
}
return updated;
});
return this.toAccountDto(row);
}
@@ -218,7 +351,7 @@ export class TreasuryService {
accountId: string,
range?: { from?: string; to?: string },
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
const account = await this.requireAccount(actor.organizationId, accountId);
const account = await this.requireAccount(actor.organizationId, accountId, actor);
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const rows = await this.prisma.financialMovement.findMany({
@@ -303,8 +436,8 @@ export class TreasuryService {
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
}
const [source, target] = await Promise.all([
this.requireAccount(actor.organizationId, input.fromAccountId),
this.requireAccount(actor.organizationId, input.toAccountId),
this.requireAccount(actor.organizationId, input.fromAccountId, actor),
this.requireAccount(actor.organizationId, input.toAccountId, actor),
]);
if (!source.isActive || !target.isActive) {
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
@@ -354,10 +487,12 @@ export class TreasuryService {
kind: 'income' | 'expense',
q?: string,
): Promise<FinancialDocumentDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
...(q?.trim()
? {
OR: [
@@ -384,7 +519,10 @@ export class TreasuryService {
bookedAt?: string;
dueAt?: string | null;
categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null;
method?: string | null;
reference?: string | null;
@@ -398,9 +536,13 @@ export class TreasuryService {
const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0)
: dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
}
@@ -408,7 +550,7 @@ export class TreasuryService {
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
}
if (input.accountId) {
const account = await this.requireAccount(actor.organizationId, input.accountId);
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (input.kind === 'expense' && settle.greaterThan(0)) {
const balance = await this.accountBalance(account.id);
@@ -419,6 +561,13 @@ export class TreasuryService {
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
actor.organizationId,
input.kind,
input.bookedAt,
bookedAt,
);
const created = await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
@@ -428,12 +577,15 @@ export class TreasuryService {
bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null,
method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null,
reference,
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
@@ -449,7 +601,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
reference: input.reference?.trim() || null,
reference,
createdById: actor.userId,
},
});
@@ -462,6 +614,221 @@ export class TreasuryService {
return this.toDocumentDto(doc);
}
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
private async nextDocumentReference(
tx: Prisma.TransactionClient,
organizationId: string,
kind: 'income' | 'expense',
bookedAtInput: string | undefined,
bookedAt: Date,
) {
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
const when = match
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
: bookedAt;
const { jy, jm, jd } = dateToJalali(when);
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
const latest = await tx.financialDocument.findFirst({
where: { organizationId, reference: { startsWith: prefix } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const current = Number(latest?.reference?.slice(prefix.length));
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
const term = q?.trim() ?? '';
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
const [people, companies, users] = await Promise.all([
this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
},
select: { id: true, firstName: true, lastName: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.company.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { name: contains } : {}),
},
select: { id: true, name: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.user.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
...(contains ? { fullName: contains } : {}),
},
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
take: 20,
}),
]);
return {
people: people.map((row) => ({
id: row.id,
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
})),
companies,
users: users.map((row) => ({ id: row.id, name: row.fullName })),
};
}
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
const firstName = input.firstName?.trim() ?? '';
const lastName = input.lastName?.trim() ?? '';
if (`${firstName} ${lastName}`.trim().length < 2) {
throw new BadRequestException('نام شخص را وارد کنید.');
}
const person = await this.people.create(actor, { firstName, lastName });
return { id: person.id, name: person.fullName, kind: 'person' as const };
}
async createPartyCompany(actor: Actor, input: { name?: string }) {
const name = input.name?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
const company = await this.companies.create(actor, { name });
return { id: company.id, name: company.name, kind: 'company' as const };
}
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
select: {
amount: true,
partyKind: true,
personId: true,
companyId: true,
userId: true,
partyName: true,
movements: { select: { amount: true, voidedAt: true } },
},
});
const buckets = new Map<string, {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);
if (!rawRemaining.greaterThan(0)) continue;
const linkedId = row.personId || row.companyId || row.userId;
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
? row.partyKind
: null;
const key = partyKind && linkedId
? `${partyKind}:${linkedId}`
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
const current = buckets.get(key) ?? {
key,
partyKind,
partyName: row.partyName?.trim() || 'بدون طرف حساب',
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current);
}
return [...buckets.values()]
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
partyKind: row.partyKind,
partyName: row.partyName,
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
}));
}
private async resolveParty(
actor: Actor,
input: {
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
},
) {
const kind = input.partyKind;
if (kind === 'other') {
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
}
if (kind === 'person') {
const id = input.personId?.trim();
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
const person = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
return {
partyKind: 'person' as const,
personId: person.id,
companyId: null,
userId: null,
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
};
}
if (kind === 'company') {
const id = input.companyId?.trim();
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
const company = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, name: true },
});
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
return {
partyKind: 'company' as const,
personId: null,
companyId: company.id,
userId: null,
partyName: company.name,
};
}
if (kind === 'user') {
const id = input.userId?.trim();
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
const user = await this.prisma.user.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
return {
partyKind: 'user' as const,
personId: null,
companyId: null,
userId: user.id,
partyName: user.fullName,
};
}
throw new BadRequestException('طرف حساب را انتخاب کنید.');
}
async settleDocument(
actor: Actor,
id: string,
@@ -482,7 +849,7 @@ export class TreasuryService {
if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
}
const account = await this.requireAccount(actor.organizationId, input.accountId);
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (doc.kind === 'expense') {
const balance = await this.accountBalance(account.id);
@@ -644,9 +1011,14 @@ export class TreasuryService {
): Promise<TreasuryDashboardDto> {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const scope = await this.accountScope(actor);
const accountFilter = scope
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
: {};
const bookedWhere = {
organizationId: actor.organizationId,
status: { not: 'void' },
...accountFilter,
...(from || to
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}),
@@ -654,6 +1026,7 @@ export class TreasuryService {
const cashWhere = {
organizationId: actor.organizationId,
voidedAt: null,
...(scope ? { accountId: { in: scope } } : {}),
...(from || to
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}),
@@ -680,12 +1053,13 @@ export class TreasuryService {
where: {
organizationId: actor.organizationId,
status: { in: ['open', 'partial'] },
...accountFilter,
},
include: { movements: true },
}),
this.listAccounts(actor),
this.prisma.financialDocument.findMany({
where: { organizationId: actor.organizationId },
where: { organizationId: actor.organizationId, ...accountFilter },
include: { category: true, account: true, movements: true },
orderBy: { createdAt: 'desc' },
take: 8,
@@ -754,11 +1128,50 @@ export class TreasuryService {
);
}
private async requireAccount(organizationId: string, id: string) {
private async accountScope(actor: Actor): Promise<string[] | null> {
if (actor.role === 'owner' || actor.role === 'admin') return null;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: {
user: { select: { fullName: true } },
orgRole: { select: { treasuryAccountIds: true, treasuryOwnAccounts: true } },
treasuryAccessOverride: true,
},
});
const override = parseUserTreasuryAccess(membership?.treasuryAccessOverride);
if (override && !override.enabled) return [];
const extra = override
? override.accountIds
: (membership?.orgRole?.treasuryAccountIds ?? []);
const own = override
? override.ownAccounts
: Boolean(membership?.orgRole?.treasuryOwnAccounts);
if (!own && extra.length === 0) return null;
const ids = new Set(extra);
const name = membership?.user.fullName?.trim();
if (own && name) {
const rows = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId, responsibleName: { not: null } },
select: { id: true, responsibleName: true },
});
for (const row of rows) {
if (row.responsibleName?.trim() === name) ids.add(row.id);
}
}
return [...ids];
}
private async requireAccount(organizationId: string, id: string, actor?: Actor) {
const row = await this.prisma.financialAccount.findFirst({
where: { id, organizationId },
});
if (!row) throw new NotFoundException('حساب پیدا نشد.');
if (actor) {
const scope = await this.accountScope(actor);
if (scope && !scope.includes(row.id)) {
throw new ForbiddenException('به این حساب دسترسی ندارید.');
}
}
return row;
}
@@ -824,7 +1237,10 @@ export class TreasuryService {
bookedAt: Date;
dueAt: Date | null;
categoryId: string | null;
partyKind?: string | null;
personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null;
accountId: string | null;
method: string | null;
@@ -851,7 +1267,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId,
categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName,
accountId: row.accountId,
accountName: row.account?.name ?? null,
+6
View File
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
@IsOptional()
@IsObject()
dataAccessOverride?: Record<string, unknown> | null;
/** null = ارث از نقش */
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
treasuryAccessOverride?: Record<string, unknown> | null;
}
+28 -6
View File
@@ -14,7 +14,12 @@ import type {
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import {
DATA_SCOPES,
DATA_VISIBILITIES,
parseDataAccessPolicy,
parseUserTreasuryAccess,
} from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
@@ -47,6 +52,7 @@ type MembershipRow = {
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
treasuryAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
@@ -370,23 +376,35 @@ export class UsersService {
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null },
dto: {
dataAccessOverride?: Record<string, unknown> | null;
treasuryAccessOverride?: Record<string, unknown> | null;
},
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
}
if (dto.dataAccessOverride === undefined) {
if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
}
const next =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
const data: Prisma.MembershipUpdateInput = {};
if (dto.dataAccessOverride !== undefined) {
data.dataAccessOverride =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
}
if (dto.treasuryAccessOverride !== undefined) {
data.treasuryAccessOverride =
dto.treasuryAccessOverride === null
? Prisma.DbNull
: (parseUserTreasuryAccess(dto.treasuryAccessOverride) ?? Prisma.DbNull);
}
await this.prisma.membership.update({
where: { id: target.id },
data: { dataAccessOverride: next },
data,
});
return this.dtoOf(organizationId, targetId);
}
@@ -606,6 +624,10 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin'
? {}
: parseDataAccessPolicy(row.dataAccessOverride),
treasuryAccessOverride:
row.role === 'owner' || row.role === 'admin'
? null
: parseUserTreasuryAccess(row.treasuryAccessOverride),
...(() => {
const effectiveDataAccess = resolveAllSectionAccess({
role: row.role,
@@ -3,13 +3,16 @@ import { computed, onMounted, ref, watch } from 'vue';
import type {
DataAccessPolicy,
DataAccessSection,
FinancialAccountDto,
PersonImportSourceStatDto,
RolePermissionKey,
SectionDataAccess,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
@@ -21,11 +24,16 @@ const props = defineProps<{
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
/** تب‌های عمق داده قفل شوند؛ تب مدیریت مالی جدا می‌ماند */
dataLocked?: boolean;
showTreasury?: boolean;
treasuryAccess?: UserTreasuryAccess | null;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
'update:treasuryAccess': [UserTreasuryAccess | null];
}>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */
@@ -47,12 +55,13 @@ const visibleSections = computed(() => {
});
});
const active = ref<DataAccessSection>('customers');
const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
const active = ref<DataAccessSection | 'treasury'>('customers');
watch(
visibleSections,
(list) => {
if (!list.includes(active.value) && list[0]) active.value = list[0];
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
@@ -89,6 +98,48 @@ const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]);
const routineCategories = ref<IdOption[]>([]);
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
const treasuryAccountQuery = ref('');
const filteredTreasuryAccounts = computed(() => {
const query = treasuryAccountQuery.value.trim();
if (!query) return treasuryAccounts.value;
return treasuryAccounts.value.filter((account) => account.name.includes(query));
});
function emptyTreasuryAccess(): UserTreasuryAccess {
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
}
function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next);
}
function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key)
? current.sections.filter((item) => item !== key)
: [...current.sections, key];
patchTreasury({ ...current, enabled: true, sections });
}
function toggleTreasuryAccount(id: string) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const accountIds = current.accountIds.includes(id)
? current.accountIds.filter((item) => item !== id)
: [...current.accountIds, id];
patchTreasury({ ...current, enabled: true, accountIds });
}
async function loadTreasuryAccounts() {
if (!props.showTreasury) return;
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
treasuryAccounts.value = data;
} catch {
treasuryAccounts.value = [];
}
}
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
@@ -176,8 +227,13 @@ async function loadOptions() {
onMounted(() => {
void loadOptions();
void loadTreasuryAccounts();
});
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
@@ -218,11 +274,94 @@ const typesForActive = computed(() => {
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
<button
v-if="showTreasury"
type="button"
class="tab"
:class="{ on: active === 'treasury' }"
role="tab"
:aria-selected="active === 'treasury'"
@click="active = 'treasury'"
>
مدیریت مالی
</button>
</div>
<div v-if="active === 'treasury'" class="treasury">
<p class="muted">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled"
@change="patchTreasury(null)"
/>
مثل نقش
</label>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess != null"
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/>
دسترسی به مدیریت مالی
</label>
<template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
<input
type="checkbox"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
/>
{{ perm.label }}
</label>
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="muted">حساب‌های دیگر</p>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
<input
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
</template>
</template>
</div>
<DataAccessEditor
v-else
:key="active"
:section="active"
:model-value="sectionValue(active)"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
@@ -231,9 +370,9 @@ const typesForActive = computed(() => {
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(active, $event)"
@update:model-value="setSection(editorSection, $event)"
/>
</template>
</div>
@@ -268,4 +407,17 @@ const typesForActive = computed(() => {
color: #64748b;
font-size: 0.85rem;
}
.treasury {
display: grid;
gap: 0.45rem;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem;
align-items: center;
}
.choice.sub {
padding-inline-start: 1.2rem;
}
</style>
+12 -4
View File
@@ -126,6 +126,7 @@ defineExpose({ refresh: load });
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<Teleport to="body">
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
@@ -170,7 +171,9 @@ defineExpose({ refresh: load });
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
@@ -186,6 +189,7 @@ defineExpose({ refresh: load });
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
</div>
</Teleport>
</div>
</template>
@@ -275,9 +279,12 @@ defineExpose({ refresh: load });
.layer {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
}
.backdrop {
@@ -289,7 +296,8 @@ defineExpose({ refresh: load });
.modal {
position: relative;
width: min(420px, calc(100vw - 2rem));
max-height: min(90vh, 640px);
max-height: calc(100vh - 2rem);
margin: auto;
overflow: auto;
background: #fff;
border-radius: 16px;
+12 -3
View File
@@ -11,6 +11,7 @@ import type {
TeamDto,
TeamMemberRole,
UpdatePriorArchiveCutoffRequest,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
@@ -279,8 +280,13 @@ async function removeTeam(id: string) {
}
}
const showTreasury = computed(() => {
const modules = auth.user?.enabledModules;
return !modules || modules.includes('treasury');
});
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const draftTreasury = ref<UserTreasuryAccess | null>(null);
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
@@ -323,6 +329,7 @@ function openAccessEditor(u: OrgUserDto) {
draftAccess.value = hasOverride
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess);
draftTreasury.value = u.treasuryAccessOverride ?? null;
expandedAccess.value = u.id;
}
@@ -333,8 +340,8 @@ async function saveUserAccess(userId: string) {
try {
const payload =
accessMode.value === 'inherit'
? { dataAccessOverride: null }
: { dataAccessOverride: draftAccess.value };
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null;
@@ -528,8 +535,10 @@ onMounted(() => {
سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact
/>
<div class="actions">
@@ -120,6 +120,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
};
}
@@ -253,40 +254,54 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return;
if (!fromStageId) return;
const from = columns.value[fromStageId]!;
const to = columns.value[toStageId];
if (!to) return;
const item = from.items.find((i) => i.id === activityId)!;
const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex];
if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!;
const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return;
let insertAt = index == null || index < 0 ? 0 : index;
if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return;
const snapshot = {
fromItems: from.items,
fromTotal: from.total,
toItems: to.items,
toTotal: to.total,
};
const prevFrom = from.items;
const prevTo = to.items;
const prevFromTotal = from.total;
const prevToTotal = to.total;
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
const nextTo = [...to.items];
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
nextTo.splice(at, 0, optimistic);
to.items = nextTo;
to.total += 1;
const nextTo = [...without];
nextTo.splice(insertAt, 0, optimistic);
if (same) {
from.items = nextTo;
} else {
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
to.items = nextTo;
to.total += 1;
}
moving.value = true;
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
position: insertAt,
});
to.items = to.items.map((i) => (i.id === activityId ? data : i));
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = snapshot.fromItems;
from.total = snapshot.fromTotal;
to.items = snapshot.toItems;
to.total = snapshot.toTotal;
from.items = prevFrom;
from.total = prevFromTotal;
if (!same) {
to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
moving.value = false;
+22 -2
View File
@@ -4,6 +4,7 @@ import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -13,10 +14,12 @@ import {
Flex as AFlex,
Space as ASpace,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
@@ -60,14 +63,26 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#eab308' },
low: { label: 'کم', color: '#16a34a' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.45 : 1,
opacity: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
}));
</script>
@@ -194,6 +209,11 @@ const cardStyle = computed(() => ({
{{ money.unit }}
</a-typography-text>
</a-typography-text>
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
</a-flex>
</a-flex>
</a-card>
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -51,6 +51,8 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false);
const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
margin: 0,
}));
function onCardDragOver(stageId: string, event: DragEvent) {
function trackDrag(event: DragEvent, index: number | null) {
event.preventDefault();
dragScrollColumn(bodyEl.value, event.clientY);
if (index != null) insertAt.value = index;
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, event);
}
function onColumnDrop(event: DragEvent) {
const at = insertAt.value ?? undefined;
clearDragUi();
emit('drop', props.stage.id, event, at);
}
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
emit('drop', stageId, event, index);
const at = insertAt.value ?? pointerDropIndex(index, event);
clearDragUi();
emit('drop', stageId, event, at);
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
}
function startRename() {
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
<template>
<section
:style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)"
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="onColumnDrop"
>
<div :style="headStyle">
<span
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
</a-dropdown>
</div>
<div role="list" :aria-label="stage.title" :style="bodyStyle">
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
@dragover="onCardDragOver(stage.id, $event)"
@dragover="onCardDragOver(stage.id, index, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
<div
:style="slotStyle(dropActive && insertAt === index)"
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
/>
<DealCard
:deal="deal"
:accent="stage.color"
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
/>
</div>
<div
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
/>
<a-typography-text
v-if="!stage.deals.length"
type="secondary"
@@ -2,31 +2,30 @@
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canPayroll = computed(
() =>
auth.user?.role === 'owner' ||
auth.user?.role === 'admin' ||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
);
const links = computed(() => [
{ value: '/finance', label: 'داشبورد', exact: true },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها' },
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
{ value: '/finance/recurring', label: 'تکرارشونده' },
{ value: '/finance/reports', label: 'گزارش' },
{ value: '/finance/audit', label: 'سوابق' },
{ value: '/finance/categories', label: 'دسته‌بندی‌ها' },
]);
const links = computed(() => {
const role = auth.user?.role;
const permissions = auth.user?.permissions;
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
});
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format';
const props = defineProps<{
rows: TreasuryPartyBalanceDto[];
settledLabel: string;
}>();
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const columns = computed(() => [
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
{
title: 'نوع',
key: 'partyKind',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
},
{
title: 'مبلغ کل',
key: 'amount',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
},
{
title: props.settledLabel,
key: 'settledAmount',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
},
]);
</script>
<template>
<a-table
:columns="columns"
:data-source="rows"
:pagination="false"
row-key="key"
:locale="{ emptyText: 'مانده‌ای نیست.' }"
/>
</template>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Button as AButton,
Flex as AFlex,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
import { UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import CompanyFormView from '@/views/CompanyFormView.vue';
import PersonFormView from '@/views/PersonFormView.vue';
const VNodes = defineComponent({
props: { vnodes: { type: Object, required: true } },
render() {
return this.vnodes;
},
});
const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography;
const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId');
type Choice = { id: string; name: string };
const people = ref<Choice[]>([]);
const companies = ref<Choice[]>([]);
const users = ref<Choice[]>([]);
const labels = ref<Record<string, string>>({});
const loading = ref(false);
const selectOpen = ref(false);
const personOpen = ref(false);
const companyOpen = ref(false);
const selectValue = computed(() => {
if (kind.value === 'other') return 'other';
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
return undefined;
});
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
if (kind.value !== group || !partyId.value) return list;
if (list.some((row) => row.id === partyId.value)) return list;
const name = labels.value[`${group}:${partyId.value}`] || 'انتخاب‌شده';
return [{ id: partyId.value, name }, ...list];
}
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
const userOptions = computed(() => withCurrent(users.value, 'user'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ثبت ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق بود.';
}
let timer = 0;
function onSearch(value: string) {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void load(value);
}, 250);
}
async function load(q = '') {
loading.value = true;
try {
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
params: { q: q.trim() || undefined },
});
people.value = data.people;
companies.value = data.companies;
users.value = data.users;
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
} catch (e) {
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
} finally {
loading.value = false;
}
}
function onChange(value: unknown) {
if (typeof value !== 'string' || !value) {
kind.value = undefined;
partyId.value = undefined;
return;
}
if (value === 'other') {
kind.value = 'other';
partyId.value = undefined;
return;
}
const splitAt = value.indexOf(':');
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
const id = value.slice(splitAt + 1);
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
kind.value = nextKind;
partyId.value = id;
}
function onDropdown(open: boolean) {
selectOpen.value = open;
}
function openPersonForm() {
selectOpen.value = false;
personOpen.value = true;
}
function openCompanyForm() {
selectOpen.value = false;
companyOpen.value = true;
}
function onPersonCreated(person?: PersonDto) {
personOpen.value = false;
if (!person?.id) return;
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
labels.value[`person:${person.id}`] = name;
kind.value = 'person';
partyId.value = person.id;
}
function onCompanyCreated(company?: CompanyDto) {
companyOpen.value = false;
if (!company?.id) return;
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
labels.value[`company:${company.id}`] = company.name;
kind.value = 'company';
partyId.value = company.id;
}
onMounted(() => {
void load();
});
</script>
<template>
<a-flex vertical :gap="4">
<a-select
:open="selectOpen"
show-search
@dropdownVisibleChange="onDropdown"
allow-clear
:filter-option="false"
:loading="loading"
:value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر"
style="width: 100%"
@search="onSearch"
@change="onChange"
>
<a-select-option value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option
v-for="row in peopleOptions"
:key="`person:${row.id}`"
:value="`person:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="companyOptions.length" label="شرکت‌ها">
<a-select-option
v-for="row in companyOptions"
:key="`company:${row.id}`"
:value="`company:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="userOptions.length" label="کاربران">
<a-select-option
v-for="row in userOptions"
:key="`user:${row.id}`"
:value="`user:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<template #dropdownRender="{ menuNode }">
<v-nodes :vnodes="menuNode" />
<a-flex @mousedown.prevent>
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
</a-flex>
</template>
</a-select>
<a-typography-text v-if="kind === 'other'" type="secondary">
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمی‌ماند.
</a-typography-text>
</a-flex>
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
<PersonFormView
v-if="personOpen"
embedded
@saved="onPersonCreated"
@cancel="personOpen = false"
/>
</UiModal>
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
<CompanyFormView
v-if="companyOpen"
embedded
@saved="onCompanyCreated"
@cancel="companyOpen = false"
/>
</UiModal>
</template>
@@ -0,0 +1,52 @@
import { ref, watch, type Ref } from 'vue';
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی می‌کند تا کاربر خودش آن را عوض کند. */
export function useSettleDefault(
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
) {
const dirty = ref(false);
let mirroring = false;
function locked() {
return form.value.partyKind === 'other';
}
function copy() {
mirroring = true;
form.value.settleAmount = form.value.amount;
mirroring = false;
}
watch(
() => form.value.amount,
() => {
if (locked() || !dirty.value) copy();
},
{ flush: 'sync' },
);
watch(
() => form.value.settleAmount,
() => {
if (!mirroring && !locked()) dirty.value = true;
},
{ flush: 'sync' },
);
watch(
() => form.value.partyKind,
(kind) => {
if (kind === 'other') {
dirty.value = false;
copy();
}
},
{ flush: 'sync' },
);
return {
reset() {
dirty.value = false;
},
};
}
+6 -3
View File
@@ -203,11 +203,14 @@ function cardAllowedForNav(card: {
to?: string;
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| Boolean(auth.user?.permissions?.includes('treasury'))
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
if (card.to?.startsWith('/finance')) return canTreasury;
|| perms.includes('treasury')
|| perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
}
const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
+74 -2
View File
@@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView
}
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
return (
file.mime.startsWith('image/') ||
(file.dataUrl ?? '').startsWith('data:image/') ||
/\.(png|jpe?g|gif|webp)$/i.test(file.name)
);
}
export type NoteChip =
| { kind: 'text'; text: string }
| { kind: 'image'; src: string; name: string }
| { kind: 'link'; href: string; label: string };
const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi;
const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi;
const HTTP_URL = /https?:\/\/[^\s<>"']+/gi;
/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا می‌کند. */
export function noteChips(raw: string | null | undefined): NoteChip[] {
const text = raw ?? '';
if (!text.trim()) return [];
const chips: NoteChip[] = [];
const token = new RegExp(
`${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`,
'gi',
);
let cursor = 0;
for (const match of text.matchAll(token)) {
const index = match.index ?? 0;
pushText(chips, text.slice(cursor, index));
chips.push(chipForToken(match[0]));
cursor = index + match[0].length;
}
pushText(chips, text.slice(cursor));
return chips;
}
function pushText(chips: NoteChip[], value: string) {
if (!value) return;
const prev = chips[chips.length - 1];
if (prev?.kind === 'text') prev.text += value;
else chips.push({ kind: 'text', text: value });
}
function chipForToken(token: string): NoteChip {
if (token.startsWith('{')) {
const file = readFileValue(token);
if (file?.dataUrl && leadNoteFileIsImage(file)) {
return { kind: 'image', src: file.dataUrl, name: file.name };
}
if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name };
return { kind: 'text', text: file?.name || 'فایل پیوست' };
}
if (token.startsWith('data:image/')) {
return { kind: 'image', src: token, name: 'تصویر پیوست' };
}
if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) };
return { kind: 'link', href: token, label: shortUrl(token) };
}
function isImageUrl(href: string): boolean {
return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href);
}
function shortUrl(href: string): string {
try {
const url = new URL(href);
const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? '');
const label = file || url.hostname;
return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label;
} catch {
return href.length > 42 ? `${href.slice(0, 28)}…` : href;
}
}
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
@@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null {
return asFile(JSON.parse(value));
} catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
if (name) return { name, mime: '', dataUrl: null };
const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? '';
if (name) return { name, mime, dataUrl: null };
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null;
}
+51 -5
View File
@@ -1,5 +1,6 @@
import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -21,6 +22,8 @@ export type PipelineDeal = {
assigneeIds?: string[];
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
};
export type PipelineUiCopy = {
@@ -344,18 +347,16 @@ export function updateDealStage(
return without.map((stage) => {
if (stage.id !== stageId) return stage;
const next = [...stage.deals];
let insertAt =
toIndex == null || toIndex < 0 || toIndex > next.length
? next.length
: toIndex;
let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex;
if (
fromStageId === stageId &&
fromIndex >= 0 &&
toIndex != null &&
toIndex > fromIndex
) {
insertAt = Math.max(0, insertAt - 1);
insertAt -= 1;
}
insertAt = Math.max(0, Math.min(insertAt, next.length));
next.splice(insertAt, 0, { ...moving!, stageId });
return { ...stage, deals: next };
});
@@ -422,6 +423,51 @@ export function toFaDealCount(n: number): string {
}
/** جابه‌جایی یک ستون به ایندکس جدید */
/** نیمهٔ پایین کارت یعنی «بعد از این کارت». ایندکس نسبت به فهرست فعلی ستون است. */
export function pointerDropIndex(index: number, event: DragEvent): number {
const el = event.currentTarget as HTMLElement | null;
const rect = el?.getBoundingClientRect();
if (!rect || rect.height <= 0) return index;
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
}
const DRAG_SCROLL_EDGE = 72;
const DRAG_SCROLL_MAX = 16;
let dragScrollEl: HTMLElement | null = null;
let dragScrollY = 0;
let dragScrollRaf = 0;
function dragScrollTick() {
dragScrollRaf = 0;
const el = dragScrollEl;
if (!el) return;
const rect = el.getBoundingClientRect();
let delta = 0;
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
}
if (!delta || el.scrollHeight <= el.clientHeight) return;
el.scrollTop += delta;
dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت می‌کند تا مقصد دیده شود. */
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
dragScrollEl = el;
dragScrollY = clientY;
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
export function stopDragScroll() {
dragScrollEl = null;
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
dragScrollRaf = 0;
}
export function moveStageToIndex(
stages: PipelineStage[],
stageId: string,
+31 -1
View File
@@ -1,4 +1,9 @@
import type { RolePermissionKey, UserRole } from '@mento/shared';
import {
treasurySectionAllowed,
type RolePermissionKey,
type TreasurySectionKey,
type UserRole,
} from '@mento/shared';
/** مسیر صفحه ← مجوز بخش؛ هم‌تراز SectionPermissionGuard در API */
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
@@ -39,6 +44,31 @@ export function hasSectionPermission(
return Boolean(permissions?.includes(key));
}
function financeSection(path: string): TreasurySectionKey | null {
const clean = path.split(/[?#]/)[0] ?? path;
if (!clean.startsWith('/finance')) return null;
if (clean.startsWith('/finance/payroll')) return 'treasury_payroll';
if (clean.startsWith('/finance/incomes')) return 'treasury_incomes';
if (clean.startsWith('/finance/expenses')) return 'treasury_expenses';
if (clean.startsWith('/finance/receivables')) return 'treasury_receivables';
if (clean.startsWith('/finance/accounts')) return 'treasury_accounts';
if (clean.startsWith('/finance/recurring')) return 'treasury_recurring';
if (clean.startsWith('/finance/reports')) return 'treasury_reports';
if (clean.startsWith('/finance/audit')) return 'treasury_audit';
if (clean.startsWith('/finance/categories')) return 'treasury_categories';
return 'treasury_dashboard';
}
export function canOpenFinancePath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
path: string,
): boolean {
const section = financeSection(path);
if (!section) return true;
return treasurySectionAllowed(role, permissions, section);
}
export function canOpenPath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
+14 -8
View File
@@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
export const router = createRouter({
history: createWebHistory(),
@@ -330,7 +330,7 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
},
},
{
@@ -369,8 +369,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.',
treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
},
},
],
@@ -678,10 +678,16 @@ router.beforeEach((to) => {
return true;
}
if (String(to.path).startsWith('/finance')) {
const perms = auth.user?.permissions ?? [];
const payroll = to.path.startsWith('/finance/payroll');
if (payroll && perms.includes('treasury_payroll')) return true;
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true;
}
const exactHome = to.path === '/finance' || to.path === '/finance/';
if (
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
}
return { name: 'home' };
}
+173 -17
View File
@@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
import {
leadNoteFileIsImage,
noteChips,
parseLeadFormNotes,
type LeadNoteFile,
type LeadNoteRow,
} from '@/lib/lead-form-notes';
const route = useRoute();
const router = useRouter();
@@ -62,6 +69,51 @@ const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
const noteOpen = ref(true);
const noteModel = computed(() => parseLeadFormNotes(activity.value?.description));
const noteRows = computed(() =>
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
);
const noteExtra = computed(() =>
noteChips(
noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description,
).filter(
(chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'),
),
);
const recoveredImages = ref<Record<string, string>>({});
function noteImageSrc(file: LeadNoteFile): string | null {
if (!leadNoteFileIsImage(file)) return null;
return file.dataUrl || recoveredImages.value[file.name] || null;
}
watch(
[() => activity.value?.id, () => activity.value?.lead?.id, noteRows],
async ([activityId, leadId]) => {
const missing = noteRows.value
.map((row) => row.file)
.filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file)))
.map((file) => file.name);
recoveredImages.value = {};
if (!activityId || !leadId || !missing.length) return;
try {
const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`);
if (activity.value?.id !== activityId) return;
const wanted = new Set(missing);
const next: Record<string, string> = {};
for (const row of parseLeadFormNotes(data.notes).rows) {
const file = row.file;
if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) {
next[file.name] = file.dataUrl;
}
}
recoveredImages.value = next;
} catch {
recoveredImages.value = {};
}
},
{ immediate: true },
);
const users = ref<OrgUserDto[]>([]);
const assignUserIds = ref<string[]>([]);
@@ -565,8 +617,61 @@ onMounted(() => {
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<dl v-if="noteRows.length" class="note-rows">
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
<dt>{{ row.label }}</dt>
<dd>
<template v-if="row.file && noteImageSrc(row.file)">
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
</a>
<span>{{ row.file.name }}</span>
</template>
<span v-else-if="row.file">{{ row.file.name }}</span>
<template v-else>
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</template>
</dd>
</div>
</dl>
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<p v-if="noteExtra.length" class="note-extra">
<template v-for="(chip, index) in noteExtra" :key="index">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</p>
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
@@ -654,7 +759,13 @@ onMounted(() => {
margin: 0;
display: grid;
gap: 0.9rem;
padding-bottom: 5.5rem;
padding-bottom: 1rem;
overflow-x: clip;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.nav {
@@ -876,6 +987,8 @@ dt {
dd {
font-weight: 700;
text-align: start;
min-width: 0;
overflow-wrap: anywhere;
}
.lead-link {
@@ -908,17 +1021,27 @@ dd {
.assignees-row dd {
flex: 1 1 16rem;
min-width: 0;
max-width: 100%;
font-weight: 500;
}
.assignees-row dd :deep(.ant-select) {
width: 100%;
max-width: 100%;
}
.assignees-row dd :deep(.ant-select-selection-overflow) {
flex-wrap: wrap;
}
.flag.low,
.priority.low {
color: #64748b;
color: #16a34a;
}
.flag.medium,
.priority.medium {
color: #ea580c;
color: #eab308;
}
.flag.high,
@@ -968,12 +1091,53 @@ dd {
line-height: 1.9;
font-size: 0.9rem;
white-space: pre-line;
overflow-wrap: anywhere;
}
.note p.empty {
color: #94a3b8;
}
.note-rows {
margin: 0.75rem 0 0;
display: grid;
gap: 0.55rem;
}
.note-rows > div {
display: grid;
grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr);
gap: 0.45rem 0.75rem;
align-items: start;
}
.note-rows dt {
color: #64748b;
font-weight: 500;
}
.note-rows dd {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.note-shot {
display: block;
max-width: 100%;
}
.note-shot img {
display: block;
width: auto;
max-width: min(280px, 100%);
max-height: 220px;
object-fit: contain;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
background: #fff;
}
.note-photos {
display: flex;
flex-wrap: wrap;
@@ -1017,13 +1181,11 @@ dd {
}
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
position: static;
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem;
z-index: 8;
width: 100%;
}
.act {
@@ -1053,15 +1215,9 @@ dd {
cursor: not-allowed;
}
@media (min-width: 900px) {
@media (max-width: 899px) {
.page {
padding-bottom: 0;
}
.dock {
position: sticky;
inset-inline: auto;
bottom: 0;
padding-bottom: calc(1rem + var(--mobile-tabbar-h));
}
}
</style>
+2 -2
View File
@@ -2257,11 +2257,11 @@ watch([id, isNew], () => void load(), { immediate: true });
}
.pri.medium {
color: #0d9488;
color: #eab308;
}
.pri.low {
color: #2563eb;
color: #16a34a;
}
.pri.very-low {
+2 -2
View File
@@ -27,7 +27,7 @@ const props = withDefaults(
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
const route = useRoute();
const router = useRouter();
@@ -152,7 +152,7 @@ async function submit() {
if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload());
if (props.embedded) {
emit('saved');
emit('saved', data);
return;
}
await router.replace({ name: 'company-detail', params: { id: data.id } });
+79 -18
View File
@@ -32,6 +32,7 @@ import {
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles';
import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
@@ -61,6 +62,8 @@ const addUnqualified = ref(false);
const deleteTarget = ref<LeadStageDto | null>(null);
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
const draggingId = ref<string | null>(null);
const insertAt = ref<number | null>(null);
const bodyEls = new Map<string, HTMLElement>();
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false;
@@ -467,42 +470,84 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
function setBodyEl(stageId: string, el: unknown) {
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
else bodyEls.delete(stageId);
}
function clearDragUi() {
insertAt.value = null;
stopDragScroll();
}
function slotStyle(active: boolean, collapseGap = false) {
return {
height: active ? '36px' : '0px',
marginTop: !active && collapseGap ? '-12px' : '0',
borderRadius: `${token.value.borderRadius}px`,
background: active ? token.value.colorPrimaryBg : 'transparent',
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
outlineOffset: '-1px',
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
overflow: 'hidden',
flexShrink: 0,
};
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
clearDragUi();
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
function onDragOver(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
dropStageId.value = stageId;
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
if (index != null) insertAt.value = index;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
event.stopPropagation();
const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
const at = index ?? insertAt.value ?? undefined;
draggingId.value = null;
dropStageId.value = null;
if (!leadId) return;
clearDragUi();
if (!leadId || moving.value) return;
const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId),
);
if (!fromCol || fromCol.stage.id === stageId) return;
const lead = fromCol.leads.find((l) => l.id === leadId);
const target = columns.value.find((c) => c.stage.id === stageId);
if (!fromCol || !target) return;
const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
const lead = fromCol.leads[fromIndex];
if (!lead) return;
const same = fromCol.stage.id === stageId;
if (same && at == null) return;
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
let insertIndex = at == null || at < 0 ? 0 : at;
if (same && at != null && at > fromIndex) insertIndex -= 1;
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
if (same && insertIndex === fromIndex) return;
const nextLeads = [...without];
nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
const prev = columns.value.map((c) => ({
...c,
leads: [...c.leads],
}));
columns.value = columns.value.map((c) => {
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
if (c.stage.id === fromCol.stage.id) {
return {
...c,
@@ -511,11 +556,7 @@ async function onDrop(stageId: string, event: DragEvent) {
};
}
if (c.stage.id === stageId) {
return {
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
return { ...c, total: c.total + 1, leads: nextLeads };
}
return c;
});
@@ -523,7 +564,7 @@ async function onDrop(stageId: string, event: DragEvent) {
moving.value = true;
error.value = '';
try {
await api.patch(`/leads/${leadId}/move`, { stageId });
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
} catch {
columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.';
@@ -543,7 +584,7 @@ onMounted(load);
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
کشیدن کارت برای تغییر ستون یا جابه‌جایی داخل همان ستون. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text>
</div>
<a-space :size="GAP">
@@ -646,20 +687,34 @@ onMounted(load);
</a-flex>
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
<div
:ref="(el) => setBodyEl(col.stage.id, el)"
:style="columnBodyStyle"
>
<div
v-for="(lead, index) in col.leads"
:key="lead.id"
@dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
>
<div
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
/>
<a-card
hoverable
:bordered="true"
:body-style="cardBodyStyle"
:style="{
cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1,
opacity: draggingId === lead.id ? 0.4 : 1,
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
boxShadow: token.boxShadow,
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
}"
@click="openLead(lead)"
>
@@ -789,6 +844,12 @@ onMounted(load);
</a-flex>
</a-flex>
</a-card>
</div>
<div
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
/>
<a-typography-text
v-if="!col.leads.length"
+12 -5
View File
@@ -312,11 +312,18 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
}
+4 -2
View File
@@ -189,14 +189,16 @@ onMounted(() => {
<code>X-Api-Key: mk_…</code>
یا
<code>Authorization: Bearer mk_…</code>
— مسیرها:
— خواندن:
<code>/api/public/v1/people</code>
و
<code>/api/public/v1/deals</code>
— ثبت لید از سایت بیرونی:
<code>POST /api/public/v1/leads</code>
</p>
<div class="row">
<UiField label="نام کلید">
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
<UiInput v-model="newKeyName" placeholder="مثلاً نام سایت" />
</UiField>
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
</div>
+131 -1
View File
@@ -10,7 +10,7 @@ import type {
UpdateTahesabConnectionRequest,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import {
UiBadge,
UiBusyOverlay,
@@ -74,6 +74,77 @@ const syncTablePageSize = ref(50);
const syncTablePage = ref(1);
const syncOnlyNew = ref(false);
const autoCount = ref('0');
const autoHours = ref<string[]>([]);
const autoSyncSaving = ref(false);
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
1: [8],
2: [8, 14],
3: [8, 12, 16],
4: [8, 12, 16, 20],
5: [8, 11, 14, 17, 20],
6: [8, 10, 12, 14, 16, 20],
};
const selectedConnection = computed(() =>
items.value.find((row) => row.id === sync.connectionId),
);
function hourLabel(hour: number) {
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
}
function fillAutoSync() {
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
.sort((a, b) => a - b)
.slice(0, 6);
autoCount.value = String(hours.length);
autoHours.value = hours.map(String);
}
function onAutoCount(value: string) {
const count = Math.min(6, Math.max(0, Number(value) || 0));
const preset = AUTO_HOUR_PRESETS[count] ?? [];
const next = autoHours.value.slice(0, count);
while (next.length < count) {
next.push(String(preset[next.length] ?? 8));
}
autoCount.value = String(count);
autoHours.value = next;
}
function setAutoHour(index: number, value: string) {
autoHours.value[index] = value;
}
async function saveAutoSync() {
if (!sync.connectionId) return;
const hours = autoHours.value.map((hour) => Number(hour));
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
return;
}
if (new Set(hours).size !== hours.length) {
error.value = 'ساعت‌ها نباید تکراری باشند.';
return;
}
autoSyncSaving.value = true;
error.value = '';
ok.value = '';
try {
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
customerSyncHours: hours,
});
ok.value = hours.length
? 'زمان‌بندی بروزرسانی ذخیره شد.'
: 'بروزرسانی خودکار خاموش شد.';
await load();
} catch (e) {
error.value = apiErr(e, 'ذخیره زمان‌بندی ناموفق بود');
} finally {
autoSyncSaving.value = false;
}
}
const syncVisibleResults = computed(() =>
syncOnlyNew.value
? syncResults.value.filter((r) => !r.alreadyLinked)
@@ -86,6 +157,7 @@ const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * syncTablePageSize.value;
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
});
watch(() => sync.connectionId, () => fillAutoSync());
watch([syncOnlyNew, syncTablePageSize], () => {
syncTablePage.value = 1;
});
@@ -178,6 +250,7 @@ async function load() {
if (!editingId.value) form.isDefault = data.length === 0;
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
fillAutoSync();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
@@ -875,6 +948,63 @@ onMounted(load);
</UiSelect>
</UiField>
<div class="sync-sub-section">
<h4 class="sync-subhead">بروزرسانی خودکار</h4>
<p class="sync-sub-desc">
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران،
مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد
همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی.
</p>
<UiField label="تعداد در روز">
<UiSelect
:model-value="autoCount"
:disabled="!sync.connectionId || autoSyncSaving"
@update:model-value="onAutoCount"
>
<option value="0">خاموش</option>
<option v-for="n in 6" :key="n" :value="String(n)">
{{ toFaDigits(n) }} بار
</option>
</UiSelect>
</UiField>
<div v-if="Number(autoCount) > 0" class="crm-form-grid-3">
<UiField
v-for="(_, index) in autoHours"
:key="index"
:label="`ساعت ${toFaDigits(index + 1)}`"
>
<UiSelect
:model-value="autoHours[index]"
:disabled="autoSyncSaving"
@update:model-value="setAutoHour(index, $event)"
>
<option v-for="hour in 24" :key="hour - 1" :value="String(hour - 1)">
{{ hourLabel(hour - 1) }}
</option>
</UiSelect>
</UiField>
</div>
<div class="sync-buttons">
<UiButton
:loading="autoSyncSaving"
:disabled="!sync.connectionId"
@click="saveAutoSync"
>
ذخیره زمان‌بندی
</UiButton>
</div>
<p v-if="selectedConnection?.customerSyncLastRunAt" class="sync-sub-desc">
آخرین اجرا:
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
<template v-if="selectedConnection.customerSyncLastSummary">
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
</template>
</p>
<p v-if="selectedConnection?.customerSyncLastError" class="sync-sub-desc">
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
</p>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4>
<p class="sync-sub-desc">
+105 -1
View File
@@ -14,6 +14,7 @@ import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type FinancialAccountDto,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
@@ -74,7 +75,16 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
conversations: ['conversations'],
conversations_groups: ['conversations'],
treasury: ['treasury'],
treasury_dashboard: ['treasury'],
treasury_incomes: ['treasury'],
treasury_expenses: ['treasury'],
treasury_receivables: ['treasury'],
treasury_accounts: ['treasury'],
treasury_payroll: ['treasury'],
treasury_recurring: ['treasury'],
treasury_reports: ['treasury'],
treasury_audit: ['treasury'],
treasury_categories: ['treasury'],
behavior: ['behavior'],
};
@@ -139,6 +149,10 @@ const roleFormDesc = ref('');
const roleFormColor = ref<RoleColor>('teal');
const roleFormParentId = ref('');
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
const roleFormAccountIds = ref<string[]>([]);
const roleFormOwnAccounts = ref(false);
const roleFormAccounts = ref<Array<{ id: string; name: string; responsibleName: string }>>([]);
const roleAccountQuery = ref('');
const deleteOpen = ref(false);
const deleteSaving = ref(false);
@@ -616,7 +630,11 @@ function openCreateRole(parentId?: string | null) {
? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
function openCreateChildRole() {
@@ -636,7 +654,40 @@ function openEditRole() {
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
function isTreasuryDetail(key: RolePermissionKey) {
return key.startsWith('treasury_');
}
const filteredRoleAccounts = computed(() => {
const query = roleAccountQuery.value.trim();
if (!query) return roleFormAccounts.value;
return roleFormAccounts.value.filter((account) => account.name.includes(query));
});
async function loadRoleAccounts() {
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
roleFormAccounts.value = data.map((account) => ({
id: account.id,
name: account.name,
responsibleName: account.responsibleName?.trim() ?? '',
}));
} catch {
roleFormAccounts.value = [];
}
}
function toggleRoleAccount(id: string) {
roleFormAccountIds.value = roleFormAccountIds.value.includes(id)
? roleFormAccountIds.value.filter((item) => item !== id)
: [...roleFormAccountIds.value, id];
}
function permOn(key: RolePermissionKey) {
@@ -644,6 +695,14 @@ function permOn(key: RolePermissionKey) {
}
function toggleFormPerm(key: RolePermissionKey) {
if (key === 'treasury' && permOn('treasury')) {
roleFormPerms.value = roleFormPerms.value.filter(
(item) => item !== 'treasury' && !item.startsWith('treasury_'),
);
roleFormAccountIds.value = [];
roleFormOwnAccounts.value = false;
return;
}
roleFormPerms.value = permOn(key)
? roleFormPerms.value.filter((p) => p !== key)
: [...roleFormPerms.value, key];
@@ -667,12 +726,16 @@ async function submitRoleForm() {
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
treasuryAccountIds: roleFormAccountIds.value,
treasuryOwnAccounts: roleFormOwnAccounts.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
@@ -1491,11 +1554,48 @@ onUnmounted(() => {
</label>
<fieldset class="perm-set">
<legend>مجوزهای دسترسی</legend>
<label v-for="perm in visibleRolePermissions" :key="perm.key" class="perm-row">
<p class="hint">
تیک مدیریت مالی در را باز می‌کند. بخش‌های ریز جدا انتخاب می‌شوند و با روشن کردن مدیریت مالی خودبه‌خود تیک نمی‌خورند.
تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخش‌ها به‌جز حقوق باز می‌مانند. با ذخیره حتی یک بخش، فقط همان‌ها می‌مانند. حقوق جداست.
</p>
<label
v-for="perm in visibleRolePermissions"
v-show="!isTreasuryDetail(perm.key) || permOn('treasury')"
:key="perm.key"
class="perm-row"
:class="{ 'perm-sub': isTreasuryDetail(perm.key) }"
>
<input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
{{ perm.label }}
</label>
</fieldset>
<fieldset v-if="permOn('treasury')" class="perm-set">
<legend>دسترسی حساب‌ها</legend>
<p class="hint">
این بخش جدا از تیک بخش‌های مالی است. می‌توانید حساب‌هایی را بدهید که خود عضو مسئول آن‌هاست،
و جدا از آن حساب‌های دیگر را هم انتخاب کنید. اگر هیچ‌کدام انتخاب نشود، همه حساب‌ها باز می‌ماند.
</p>
<label class="perm-row">
<input
type="checkbox"
:checked="roleFormOwnAccounts"
@change="roleFormOwnAccounts = !roleFormOwnAccounts"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="hint">حساب‌های دیگر</p>
<input v-model="roleAccountQuery" placeholder="جستجوی حساب" />
<label v-for="account in filteredRoleAccounts" :key="account.id" class="perm-row">
<input
type="checkbox"
:checked="roleFormAccountIds.includes(account.id)"
@change="toggleRoleAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!roleFormAccounts.length" class="hint">حسابی برای انتخاب نیست.</p>
</fieldset>
<fieldset class="perm-set">
<legend>محدوده داده</legend>
<DataAccessPolicyPanel
@@ -2381,6 +2481,10 @@ textarea {
color: #0f172a;
}
.perm-sub {
padding-inline-start: 1.4rem;
}
.sheet-handle,
.sheet-backdrop {
display: none;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
@@ -18,10 +18,15 @@ import {
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const isManager = computed(() => !!shell.context.value?.isManager);
const selfId = computed(() => auth.user?.id ?? '');
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
@@ -60,6 +65,8 @@ const recurrence = ref<RecurrenceValue>({
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' },
@@ -111,10 +118,14 @@ function fill(r: RoutineDto) {
}
onMounted(async () => {
if (!id.value) return;
if (!id.value) {
if (selfId.value) form.assigneeId = selfId.value;
return;
}
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
loaded.value = data;
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
@@ -123,6 +134,23 @@ onMounted(async () => {
}
});
watch(
[() => shell.context.value, loaded, selfId],
() => {
const ctx = shell.context.value;
const data = loaded.value;
if (!ctx || !data || ctx.isManager) {
foreign.value = false;
return;
}
foreign.value = data.createdById !== selfId.value;
if (foreign.value) {
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش می‌تواند تغییرش دهد.';
}
},
{ immediate: true },
);
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
@@ -169,6 +197,11 @@ const summary = computed(() => {
});
function validate(): string | null {
if (!isManager.value) {
form.target = 'user';
form.assigneeId = selfId.value;
form.teamId = '';
}
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
@@ -220,7 +253,7 @@ async function save() {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body);
shell.notifyChanged();
void router.push('/routines/manage');
void router.push(backTo.value);
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
@@ -234,10 +267,11 @@ async function save() {
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
:back-to="backTo"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<p v-else-if="foreign" class="error">{{ error }}</p>
<form v-else class="grid" @submit.prevent="save">
<div class="col">
@@ -268,7 +302,8 @@ async function save() {
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div class="fields">
<p v-if="!isManager" class="self-note">این روتین فقط برای خودتان ساخته می‌شود.</p>
<div v-else class="fields">
<UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId">
@@ -508,6 +543,13 @@ async function save() {
gap: var(--sp-2);
}
.self-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.error {
margin: 0;
}
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import {
PRIORITY_LABELS,
@@ -16,6 +18,8 @@ import {
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? '');
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
@@ -48,7 +52,7 @@ const filtered = computed(() => {
<template>
<div class="rlist">
<div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p>
<p class="lead">روتین‌های شما. کارهای هر روز از روی این‌ها ساخته می‌شوند. با «روتین جدید» می‌توانید برای خودتان برنامه تکراری بسازید.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div>
@@ -58,8 +62,8 @@ const filtered = computed(() => {
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند."
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما نیست'"
description="با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده می‌شود."
/>
<div v-else class="cards">
@@ -90,6 +94,9 @@ const filtered = computed(() => {
<small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small>
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`" class="edit">
ویرایش
</RouterLink>
</footer>
</article>
</div>
@@ -239,6 +246,13 @@ const filtered = computed(() => {
font-size: var(--fs-xs);
}
.edit {
justify-self: start;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--accent);
}
.error {
margin: 0;
}
@@ -77,7 +77,7 @@ const tabs: Tab[] = [
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
watch(
[() => route.name, context],
@@ -106,7 +106,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
</RouterLink>
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
<UiButton variant="outline" @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید
</UiButton>
@@ -23,7 +23,7 @@ import {
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
@@ -35,12 +35,15 @@ const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const error = ref('');
const ok = ref('');
const openForm = ref(false);
const editingId = ref<string | null>(null);
const responsibleKey = ref<string | undefined>(undefined);
const saving = ref(false);
const transferOpen = ref(false);
const form = ref({
const emptyForm = () => ({
name: '',
type: 'cash' as FinancialAccountType,
bankName: '',
@@ -50,6 +53,7 @@ const form = ref({
openingDate: '',
notes: '',
});
const form = ref(emptyForm());
const transfer = ref({
fromAccountId: '',
toAccountId: '',
@@ -80,9 +84,54 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
const responsibleOptions = computed(() => {
const rows = users.value.map((user) => ({ id: user.id, fullName: user.fullName }));
const current = form.value.responsibleName.trim();
if (current && !rows.some((row) => row.fullName === current)) {
rows.unshift({ id: `name:${current}`, fullName: current });
}
return rows;
});
async function load() {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data;
try {
const usersRes = await api.get<OrgUserDto[]>('/users');
users.value = usersRes.data;
} catch {
users.value = [];
}
}
function resetForm() {
editingId.value = null;
responsibleKey.value = undefined;
form.value = emptyForm();
}
function startEdit(item: FinancialAccountDto) {
editingId.value = item.id;
form.value = {
name: item.name,
type: item.type,
bankName: item.bankName ?? '',
accountRef: item.accountRef ?? '',
responsibleName: item.responsibleName ?? '',
openingBalance: item.openingBalance,
openingDate: item.openingDate.slice(0, 10),
notes: item.notes ?? '',
};
const match = users.value.find((user) => user.fullName === item.responsibleName);
responsibleKey.value = match?.id ?? (item.responsibleName ? `name:${item.responsibleName}` : undefined);
openForm.value = true;
}
function responsibleNameFromKey() {
const key = responsibleKey.value;
if (!key) return null;
if (key.startsWith('name:')) return key.slice(5);
return users.value.find((user) => user.id === key)?.fullName ?? null;
}
function onFinishFailed() {
@@ -95,23 +144,21 @@ async function createAccount() {
ok.value = '';
try {
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
await api.post('/treasury/accounts', {
const body = {
...form.value,
responsibleName: responsibleNameFromKey(),
openingBalance,
openingDate: form.value.openingDate || undefined,
});
openForm.value = false;
form.value = {
name: '',
type: 'cash',
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
};
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
if (editingId.value) {
await api.patch(`/treasury/accounts/${editingId.value}`, body);
ok.value = 'حساب به‌روز شد. موجودی از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.';
} else {
await api.post('/treasury/accounts', body);
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
}
openForm.value = false;
resetForm();
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -193,12 +240,18 @@ onMounted(() => {
<a-card :bordered="false">
<a-space>
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
<a-button
type="primary"
@click="
openForm = !openForm;
if (!openForm) resetForm();
"
>افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
<a-form
layout="vertical"
:model="form"
@@ -217,10 +270,15 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
<a-select-option value="petty">تنخواه</a-select-option>
<a-select
v-model:value="form.type"
show-search
option-filter-prop="label"
style="width: 100%"
>
<a-select-option value="cash" label="صندوق نقدی">صندوق نقدی</a-select-option>
<a-select-option value="bank" label="حساب بانکی">حساب بانکی</a-select-option>
<a-select-option value="petty" label="تنخواه">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -235,8 +293,24 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName">
<a-input v-model:value="form.responsibleName" />
<a-form-item label="مسئول">
<a-select
v-model:value="responsibleKey"
show-search
allow-clear
option-filter-prop="label"
style="width: 100%"
placeholder="انتخاب کاربر"
>
<a-select-option
v-for="user in responsibleOptions"
:key="user.id"
:value="user.id"
:label="user.fullName"
>
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -255,7 +329,9 @@ onMounted(() => {
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
<a-button type="primary" html-type="submit" :loading="saving">
{{ editingId ? 'ذخیره تغییرات' : 'ثبت حساب' }}
</a-button>
</a-form>
</template>
@@ -276,11 +352,18 @@ onMounted(() => {
>
<a-select
v-model:value="transfer.fromAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
<a-select-option
v-for="account in active"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
@@ -294,6 +377,8 @@ onMounted(() => {
>
<a-select
v-model:value="transfer.toAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
@@ -302,6 +387,7 @@ onMounted(() => {
v-for="account in active"
:key="`to-${account.id}`"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -339,6 +425,7 @@ onMounted(() => {
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
@@ -347,7 +434,9 @@ onMounted(() => {
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
{{ typeLabel[item.type] }}
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
· موجودی اولیه {{ toman(item.openingBalance) }}
</template>
</a-list-item-meta>
</a-list-item>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
@@ -13,20 +13,38 @@ import {
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal as AModal,
Popconfirm as APopconfirm,
Row as ARow,
Select as ASelect,
Switch as ASwitch,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]);
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
const error = ref('');
const saving = ref(false);
const editOpen = ref(false);
const editSaving = ref(false);
const edit = ref({ id: '', name: '', isActive: true });
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
@@ -35,6 +53,7 @@ async function load() {
async function add() {
error.value = '';
saving.value = true;
try {
await api.post('/treasury/categories', {
name: form.value.name,
@@ -43,66 +62,160 @@ async function add() {
form.value.name = '';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function openEdit(item: FinancialCategoryDto) {
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
editOpen.value = true;
}
async function remove(item: FinancialCategoryDto) {
error.value = '';
try {
await api.delete(`/treasury/categories/${item.id}`);
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
function saveEdit() {
const name = edit.value.name.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return Promise.reject(new Error('invalid'));
}
editSaving.value = true;
error.value = '';
return api
.patch(`/treasury/categories/${edit.value.id}`, {
name,
isActive: edit.value.isActive,
})
.then(() => load())
.catch((e: unknown) => {
error.value = apiMessage(e);
return Promise.reject(e);
})
.finally(() => {
editSaving.value = false;
});
}
onMounted(() => {
void load();
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-typography-text type="secondary">
دسته‌ها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده می‌شوند.
</a-typography-text>
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item
label="نام"
name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]"
<a-card :bordered="false">
<a-divider orientation="right">دسته جدید</a-divider>
<a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item
label="نام"
name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
<a-popconfirm
title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
ok-text="حذف"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
@confirm="remove(item)"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-button type="link" danger>حذف</a-button>
</a-popconfirm>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
</a-tag>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
<a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
<a-popconfirm
title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
ok-text="حذف"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
@confirm="remove(item)"
>
<a-button type="link" danger>حذف</a-button>
</a-popconfirm>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
<a-modal
v-model:open="editOpen"
title="ویرایش دسته"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="editSaving"
@ok="saveEdit"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input v-model:value="edit.name" />
</a-form-item>
<a-form-item label="فعال در فرم‌ها">
<a-switch v-model:checked="edit.isActive" />
</a-form-item>
</a-form>
</a-modal>
</a-flex>
</template>
@@ -19,8 +19,17 @@ import {
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -41,13 +51,14 @@ const form = ref({
title: '',
amount: '',
categoryId: '',
partyName: '',
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = {
@@ -71,13 +82,29 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc] = await Promise.all([
const [docs, cats, acc, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
@@ -96,31 +123,44 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', {
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
settleMirror.reset();
form.value = {
title: '',
amount: '',
categoryId: '',
partyName: '',
partyKind: undefined,
partyId: undefined,
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
};
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -180,37 +220,37 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" />
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
{{ account.name }}
</a-select-option>
</a-select>
@@ -218,22 +258,20 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value" :label="opt.label">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداخت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
@@ -242,7 +280,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -268,11 +306,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -15,7 +15,6 @@ import {
Grid,
Input as AInput,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
@@ -34,10 +33,7 @@ import {
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
@@ -45,10 +41,15 @@ import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -70,9 +72,6 @@ const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
@@ -80,17 +79,18 @@ function emptyForm() {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyName: '',
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc, dash] = await Promise.all([
const [docs, cats, acc, dash, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
settleMirror.reset();
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
@@ -167,22 +184,34 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', {
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -211,30 +240,6 @@ async function voidDoc(id: string) {
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
@@ -358,10 +363,8 @@ onMounted(() => {
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -372,11 +375,12 @@ onMounted(() => {
<span>روش دریافت</span>
</a-space>
</template>
<a-select v-model:value="form.method" style="width: 100%">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
@@ -392,21 +396,16 @@ onMounted(() => {
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="width: 100%"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="[12, 0]">
@@ -425,7 +424,12 @@ onMounted(() => {
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
<MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
@@ -471,7 +475,7 @@ onMounted(() => {
<span>حساب مقصد</span>
</a-space>
</template>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
@@ -481,6 +485,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -491,12 +496,6 @@ onMounted(() => {
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
@@ -509,13 +508,15 @@ onMounted(() => {
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -538,11 +539,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -612,31 +609,31 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" />
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
@@ -646,6 +643,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -654,11 +652,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
@@ -670,11 +669,6 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
@@ -684,6 +678,8 @@ onMounted(() => {
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
@@ -692,7 +688,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -718,11 +714,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -731,23 +723,5 @@ onMounted(() => {
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -166,13 +166,13 @@ onMounted(() => {
name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.userId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName">
{{ user.fullName }}
</a-select-option>
</a-select>
@@ -239,7 +239,7 @@ onMounted(() => {
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="pay.accountId"
allow-clear
style="width: 100%"
@@ -249,6 +249,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -244,13 +244,13 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
{{ account.name }}
</a-select-option>
</a-select>
@@ -263,9 +263,9 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -329,7 +329,7 @@ onMounted(() => {
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="collect.accountId"
allow-clear
style="width: 100%"
@@ -339,6 +339,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -111,9 +111,9 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -128,10 +128,10 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select-option value="yearly">سالانه</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
<a-select-option value="yearly" label="سالانه">سالانه</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
@@ -9,16 +10,21 @@ import {
Flex as AFlex,
List as AList,
Table as ATable,
Tabs as ATabs,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import * as XLSX from 'xlsx';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane;
type Period = {
bookedIncome: string;
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
const reportTab = ref('periods');
const error = ref('');
const balanceError = ref('');
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const lines = [
['درآمد ثبت‌شده', 'bookedIncome'],
@@ -81,6 +97,50 @@ async function load() {
receivablesByParty.value = data.receivablesByParty;
}
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
}
function exportParties(
rows: TreasuryPartyBalanceDto[],
title: string,
settledLabel: string,
filename: string,
) {
const body = rows.map((row) => [
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
Number(row.amount),
Number(row.settledAmount),
Number(row.remaining),
]);
const totals = rows.reduce(
(sum, row) => ({
amount: sum.amount + Number(row.amount || 0),
settled: sum.settled + Number(row.settledAmount || 0),
remaining: sum.remaining + Number(row.remaining || 0),
}),
{ amount: 0, settled: 0, remaining: 0 },
);
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
const sheet = XLSX.utils.aoa_to_sheet([
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
...body,
]);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, title);
XLSX.writeFile(book, filename);
}
async function loadBalances() {
const [income, expense] = await Promise.all([
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
]);
debtors.value = income.data;
creditors.value = expense.data;
}
async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
@@ -95,6 +155,14 @@ onMounted(() => {
void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.';
});
void loadBalances().catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
debtors.value = [];
creditors.value = [];
return;
}
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
});
});
</script>
@@ -108,9 +176,12 @@ onMounted(() => {
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
<a-card :bordered="false">
<a-divider orientation="right">مقایسه دوره‌ها</a-divider>
<a-tabs v-model:activeKey="reportTab">
<a-tab-pane key="periods" tab="مقایسه دوره‌ها">
<a-flex vertical :gap="16">
<a-table
v-if="metricRows.length"
:columns="metricColumns"
@@ -168,6 +239,39 @@ onMounted(() => {
</template>
</a-list>
</template>
</a-flex>
</a-tab-pane>
<a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
</a-typography-text>
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافت‌شده', 'debtors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافت‌شده" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
</a-typography-text>
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداخت‌شده', 'creditors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداخت‌شده" />
</a-flex>
</a-tab-pane>
</a-tabs>
</a-card>
</a-flex>
</template>
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
location / {
try_files $uri $uri/ /index.html;
}
}
+145 -2
View File
@@ -424,6 +424,11 @@ export interface OrgUserDto {
* اگر کل آبجکت خالی باشد و نقش هم خالی باشد → بدون محدودیت عمقی.
*/
dataAccessOverride?: DataAccessPolicy;
/**
* null = دسترسی مالی از نقش می‌آید.
* آبجکت = جایگزین بخش‌ها و حساب‌های مالی همین کاربر.
*/
treasuryAccessOverride?: UserTreasuryAccess | null;
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
effectiveDataAccess?: DataAccessPolicy;
/** @deprecated از effectiveDataAccess.customers استفاده کنید */
@@ -596,6 +601,39 @@ export interface UpdateUserDataAccessRequest {
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک می‌شود).
*/
dataAccessOverride?: DataAccessPolicy | null;
/**
* null = دسترسی مالی از نقش.
* نبودن فیلد یعنی این ذخیره به دسترسی مالی دست نمی‌زند.
*/
treasuryAccessOverride?: UserTreasuryAccess | null;
}
/** دسترسی مالی اختصاصی یک کاربر؛ جایگزین نقش می‌شود */
export type UserTreasuryAccess = {
enabled: boolean;
sections: RolePermissionKey[];
ownAccounts: boolean;
accountIds: string[];
};
export function parseUserTreasuryAccess(raw: unknown): UserTreasuryAccess | null {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return null;
const obj = raw as Record<string, unknown>;
const sections = Array.isArray(obj.sections)
? obj.sections.filter(
(item): item is RolePermissionKey =>
typeof item === "string" && item.startsWith("treasury_"),
)
: [];
const accountIds = Array.isArray(obj.accountIds)
? obj.accountIds.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [];
return {
enabled: obj.enabled === true,
sections: [...new Set(sections)],
ownAccounts: obj.ownAccounts === true,
accountIds: [...new Set(accountIds.map((id) => id.trim()))],
};
}
export function emptySectionDataAccess(): SectionDataAccess {
@@ -741,7 +779,16 @@ export type RolePermissionKey =
| "conversations"
| "conversations_groups"
| "treasury"
| "treasury_dashboard"
| "treasury_incomes"
| "treasury_expenses"
| "treasury_receivables"
| "treasury_accounts"
| "treasury_payroll"
| "treasury_recurring"
| "treasury_reports"
| "treasury_audit"
| "treasury_categories"
| "behavior";
/** بخشی که مسئولش را می‌شود به کاربر دیگری داد */
@@ -787,10 +834,59 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "conversations", label: "گفتگو" },
{ key: "conversations_groups", label: "ساخت گروه گفتگو" },
{ key: "treasury", label: "مدیریت مالی" },
{ key: "treasury_payroll", label: "حقوق و دستمزد" },
{ key: "treasury_dashboard", label: "مالی — داشبورد" },
{ key: "treasury_incomes", label: "مالی — دریافت‌ها و درآمدها" },
{ key: "treasury_expenses", label: "مالی — پرداخت‌ها و هزینه‌ها" },
{ key: "treasury_receivables", label: "مالی — اقساط و مطالبات" },
{ key: "treasury_accounts", label: "مالی — صندوق و حساب‌ها" },
{ key: "treasury_payroll", label: "مالی — حقوق و دستمزد" },
{ key: "treasury_recurring", label: "مالی — تکرارشونده" },
{ key: "treasury_reports", label: "مالی — گزارش" },
{ key: "treasury_audit", label: "مالی — سوابق" },
{ key: "treasury_categories", label: "مالی — دسته‌بندی" },
{ key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" },
];
/** بخش‌های ریز مدیریت مالی. حقوق جداست و با تیک خودش باز می‌شود. */
export const TREASURY_SECTION_KEYS = [
"treasury_dashboard",
"treasury_incomes",
"treasury_expenses",
"treasury_receivables",
"treasury_accounts",
"treasury_payroll",
"treasury_recurring",
"treasury_reports",
"treasury_audit",
"treasury_categories",
] as const satisfies readonly RolePermissionKey[];
export type TreasurySectionKey = (typeof TREASURY_SECTION_KEYS)[number];
/**
* دسترسی کلی «مدیریت مالی» در را باز می‌کند.
* اگر هیچ بخش ریزی انتخاب نشده باشد همه بخش‌ها به‌جز حقوق بازند.
* با انتخاب حتی یک بخش ریز، فقط همان‌ها باز می‌مانند.
*/
export function treasurySectionAllowed(
role: string | null | undefined,
permissions: readonly string[] | null | undefined,
section: TreasurySectionKey | "treasury_documents",
): boolean {
if (role === "owner" || role === "admin") return true;
const perms = permissions ?? [];
if (section === "treasury_payroll") return perms.includes("treasury_payroll");
if (!perms.includes("treasury")) return false;
const fine = TREASURY_SECTION_KEYS.filter(
(key) => key !== "treasury_payroll" && perms.includes(key),
);
if (fine.length === 0) return true;
if (section === "treasury_documents") {
return perms.includes("treasury_incomes") || perms.includes("treasury_expenses");
}
return perms.includes(section);
}
export interface OrgRoleMemberDto {
id: string;
fullName: string;
@@ -816,6 +912,10 @@ export interface OrgRoleDto {
childCount: number;
userCount: number;
members: OrgRoleMemberDto[];
/** حساب‌های اضافی؛ خالی به‌تنهایی یعنی هنوز محدود نشده */
treasuryAccountIds: string[];
/** true یعنی اعضای نقش حساب‌هایی را می‌بینند که خودشان مسئول آن‌ها هستند */
treasuryOwnAccounts: boolean;
}
export interface CreateOrgRoleRequest {
@@ -826,6 +926,10 @@ export interface CreateOrgRoleRequest {
dataAccessPolicy?: DataAccessPolicy;
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
parentId?: string | null;
/** حساب‌های اضافی، جدا از حساب‌هایی که خودش مسئول است */
treasuryAccountIds?: string[];
/** دسترسی به حساب‌هایی که نام خود عضو در مسئول ثبت شده */
treasuryOwnAccounts?: boolean;
}
export interface UpdateOrgRoleRequest {
@@ -836,6 +940,10 @@ export interface UpdateOrgRoleRequest {
dataAccessPolicy?: DataAccessPolicy;
/** null یعنی جدا کردن از والد و تبدیل به ریشه */
parentId?: string | null;
/** حساب‌های اضافی، جدا از حساب‌هایی که خودش مسئول است */
treasuryAccountIds?: string[];
/** دسترسی به حساب‌هایی که نام خود عضو در مسئول ثبت شده */
treasuryOwnAccounts?: boolean;
}
export interface InviteUserRequest {
@@ -4179,6 +4287,11 @@ export interface TahesabConnectionDto {
lastHealthAt: string | null;
lastHealthOk: boolean | null;
lastError: string | null;
/** ساعت‌های ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */
customerSyncHours: number[];
customerSyncLastRunAt: string | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: string;
updatedAt: string;
}
@@ -4193,7 +4306,11 @@ export interface CreateTahesabConnectionRequest {
allowInsecureTls?: boolean;
}
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>;
export interface UpdateTahesabConnectionRequest
extends Partial<CreateTahesabConnectionRequest> {
/** آرایهٔ خالی بروزرسانی خودکار را خاموش می‌کند. */
customerSyncHours?: number[];
}
export interface PersonAccountingLinkDto {
id: string;
@@ -6153,6 +6270,8 @@ export interface RoutineDto {
entityLink: RoutineEntityLink;
reminderMinutesBefore: number | null;
isActive: boolean;
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش می‌کند */
createdById: string | null;
recurrenceLabel: string;
/** درصد اجرا در ۳۰ روز اخیر */
executionRate: number | null;
@@ -6353,6 +6472,7 @@ export interface RoutineUserSettingsDto {
export type FinancialAccountType = "bank" | "cash" | "petty";
export type FinancialDocKind = "income" | "expense";
export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
export type FinancialPartyKind = "person" | "company" | "user" | "other";
export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
export interface FinancialAccountDto {
@@ -6391,7 +6511,10 @@ export interface FinancialDocumentDto {
dueAt: string | null;
categoryId: string | null;
categoryName: string | null;
partyKind: FinancialPartyKind | null;
personId: string | null;
companyId: string | null;
userId: string | null;
partyName: string | null;
accountId: string | null;
accountName: string | null;
@@ -6444,6 +6567,26 @@ export interface FinancialReceivableDto {
createdAt: string;
}
export interface TreasuryPartyChoiceDto {
id: string;
name: string;
}
export interface TreasuryPartySearchDto {
people: TreasuryPartyChoiceDto[];
companies: TreasuryPartyChoiceDto[];
users: TreasuryPartyChoiceDto[];
}
export interface TreasuryPartyBalanceDto {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: string;
settledAmount: string;
remaining: string;
}
export interface TreasuryDashboardDto {
bookedIncome: string;
actualReceipts: string;