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") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees") assignedActivities Activity[] @relation("ActivityAssignees")
@@ -353,6 +354,10 @@ model OrgRole {
permissions String[] @default([]) permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}} /// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}") dataAccessPolicy Json @default("{}")
/// حساب‌های مالی اضافی این نقش؛ همراه با حساب‌هایی که خودش مسئول است
treasuryAccountIds String[] @default([])
/// اعضای نقش، حساب‌هایی را ببینند که نام خودشان مسئول آن است
treasuryOwnAccounts Boolean @default(false)
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String? systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -387,6 +392,8 @@ model Membership {
dataVisibilityScopes Json @default("{}") dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy /// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}") dataAccessOverride Json @default("{}")
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخش‌ها و حساب‌های مالی همین کاربر
treasuryAccessOverride Json?
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد /// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false) priorArchiveEnabled Boolean @default(false)
/// current | archive /// current | archive
@@ -518,6 +525,7 @@ model Company {
customFields Json @default("{}") customFields Json @default("{}")
groupLinks CompanyGroupLink[] groupLinks CompanyGroupLink[]
people Person[] people Person[]
financialDocuments FinancialDocument[]
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
smartNotes Note[] smartNotes Note[]
@@ -896,6 +904,8 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String? stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
/// ترتیب کارت داخل همان ستون کانبان
boardPosition Int @default(0)
/// نوع سفارشی؛ null → نوع اصلیِ همان type /// نوع سفارشی؛ null → نوع اصلیِ همان type
typeDefId String? typeDefId String?
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
@@ -911,6 +921,7 @@ model Activity {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([stageId, boardPosition])
@@index([boardId]) @@index([boardId])
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId]) @@index([leadId])
@@ -1832,6 +1843,13 @@ model AccountingConnection {
lastHealthAt DateTime? lastHealthAt DateTime?
lastHealthOk Boolean? lastHealthOk Boolean?
lastError String? lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[] personLinks PersonAccountingLink[]
@@ -2321,6 +2339,12 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) 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? partyName String?
accountId String? accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2341,6 +2365,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt]) @@index([organizationId, kind, bookedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([companyId])
@@index([userId])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -3760,3 +3786,9 @@ model AttendanceEvent {
@@index([organizationId, userId, at]) @@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt]) @@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 { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatClock, formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id; return id;
}; };
const resolvePerson = async (row: Record<string, string>) => { const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase(); const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null; if (!key) return null;
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -428,7 +428,13 @@ export class ActivitiesController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: MoveActivityStageDto, @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') @Patch(':id/complete')
+39 -4
View File
@@ -31,6 +31,7 @@ import {
ownerWhere, ownerWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access'; import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
@@ -329,9 +330,7 @@ export class ActivitiesService {
where, where,
include, include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId orderBy: this.listOrder(entity, stageScope),
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take, skip: (page - 1) * take,
take, take,
}), }),
@@ -385,7 +384,7 @@ export class ActivitiesService {
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where, where,
select: { id: true }, select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], orderBy: this.listOrder(entity, stageScope),
take: cap, take: cap,
}), }),
this.prisma.activity.count({ where }), this.prisma.activity.count({ where }),
@@ -490,11 +489,46 @@ export class ActivitiesService {
return { counts, total }; 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( async moveToStage(
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
stageId: string, stageId: string,
position?: number,
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
@@ -519,6 +553,7 @@ export class ActivitiesService {
}, },
include, include,
}); });
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
if (stage.kind === 'done' && existing?.status !== 'done') { if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
+8 -2
View File
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
export class MoveActivityStageDto { export class MoveActivityStageDto {
@IsString() @IsString()
stageId!: string; stageId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
} }
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */ /** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -316,7 +322,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -416,7 +422,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @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 { PeopleController } from './people.controller';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module'; import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule, CustomerGroupsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} 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, ''); const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null; 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 { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { import {
asAddresses, asAddresses,
asCustomFields, asCustomFields,
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date }, input: { mobile?: string | null; email?: string | null; birthDate: Date },
) { ) {
const or: Prisma.PersonWhereInput[] = []; 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 (input.email) or.push({ email: input.email });
if (!or.length) return; if (!or.length) return;
await this.prisma.person.updateMany({ await this.prisma.person.updateMany({
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
importSource: normalizePersonImportSource(dto.importSource), importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef), sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email), email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone), phone: storedPhoneNumber(dto.phone),
mobile: normalizePhone(dto.mobile), mobile: storedPhoneNumber(dto.mobile),
position: emptyToNull(dto.position), position: emptyToNull(dto.position),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
} }
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email); if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone); if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position); if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website); if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); 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 email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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; if (!or.length) return;
const clash = await this.prisma.person.findFirst({ 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'; } from '@mento/shared';
import { parseBirthDate } from '../../common/excel'; import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service'; 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 { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope'; import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service'; import { MontifyConnectionsService } from './connections.service';
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
} }
function mobileVariants(mobile: string): string[] { function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, ''); return phoneIdentityKeys(mobile);
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}`])];
} }
function formatAddress(a: MontifyAddress): string | null { function formatAddress(a: MontifyAddress): string | null {
@@ -417,7 +415,7 @@ export class MontifySyncService {
}> { }> {
const externalId = String(c.id); const externalId = String(c.id);
const { firstName, lastName } = splitName(c); const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null); const mobile = storedPhoneNumber(c.phone ?? null);
const email = normalizeEmail(c.email ?? null); const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = { const profile: CustomerProfile = {
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null; lastHealthAt: Date | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk, lastHealthOk: row.lastHealthOk,
lastError: row.lastError, 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(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}; };
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); 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 = { let enc = {
ciphertext: row.tokenCiphertext, ciphertext: row.tokenCiphertext,
iv: row.tokenIv, iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled, enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
}, },
}); });
return this.toDto(updated); 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() @IsOptional()
@IsBoolean() @IsBoolean()
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
} }
export class ManualLinkDto { export class ManualLinkDto {
@@ -8,7 +8,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; 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 { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; 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 */ /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000; const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000; const LIST_TIMEOUT_MS = 55_000;
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
organizationId: string, organizationId: string,
dto: ImportFromTahesabDto, dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> }, prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) { ) {
const clash = await this.prisma.personAccountingLink.findFirst({ const clash = await this.prisma.personAccountingLink.findFirst({
where: { where: {
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName; const firstName = named.firstName;
const lastName = named.lastName; const lastName = named.lastName;
const mobile = tel || null; const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile ...canonicalMobileUpdate(mobile, existing),
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const byMobile = mobile const mobileKeys = phoneIdentityKeys(mobile);
? await this.prisma.person.findFirst({ const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: { where: {
organizationId, organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
accountingLinks: { none: { connectionId: dto.connectionId } },
}, },
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) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
}, },
}); });
personId = byMobile.id; 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({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
}); });
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId: dto.connectionId, connectionId,
kind: 'import', kind: 'customer-sync',
status: 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue, result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}, },
}); });
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { personId, link: this.linkDto(link) }; return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
} }
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service'; import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller'; import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService, TahesabAsnadReportService,
TahesabInvoiceSettingsService, TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`); variants.add(`0${d}`);
variants.add(`98${d}`); variants.add(`98${d}`);
} }
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants]; return [...variants];
} }
@@ -6,7 +6,11 @@ import type {
WooCommerceSyncResultDto, WooCommerceSyncResultDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; 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 { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client'; import type { WooOrder } from './woo-client';
@@ -374,7 +378,8 @@ export class WooSyncService {
): Promise<string | null> { ): Promise<string | null> {
const billing = order.billing ?? {}; const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null); 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 firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -387,7 +392,9 @@ export class WooSyncService {
organizationId, organizationId,
OR: [ OR: [
...(email ? [{ email }] : []), ...(email ? [{ email }] : []),
...(mobile ? [{ mobile }, { phone: mobile }] : []), ...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
], ],
}, },
select: { id: true }, 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 { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { import {
canonicalIranMobile,
emptyToNull, emptyToNull,
normalizeEmail, normalizeEmail,
normalizePhone, normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize'; } from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service'; import { LeadStagesService } from '../leads/lead-stages.service';
@@ -458,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId: form.organizationId, organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
}, },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}) })
@@ -641,13 +647,16 @@ export class LeadFormsService {
ownerId: string, ownerId: string,
): Promise<string> { ): Promise<string> {
const email = normalizeEmail(mapped.email); const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile); const mobile = storedPhoneNumber(mapped.mobile);
const phone = normalizePhone(mapped.phone); const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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 const existing = or.length
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { organizationId, OR: or }, where: { organizationId, OR: or },
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
} }
function mobileKey(raw: string | null | undefined): string | null { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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;
} }
function normalizeDuplicateMode(raw?: string | null): string { function normalizeDuplicateMode(raw?: string | null): string {
+140 -28
View File
@@ -32,12 +32,18 @@ import {
forScope, forScope,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { import {
asAddresses, asAddresses,
collectAddresses, collectAddresses,
toJsonAddresses, toJsonAddresses,
} from '../common/custom-fields'; } 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 { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.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 { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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; 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 { function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -322,10 +335,7 @@ export class LeadsService {
}; };
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, mobile),
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}); });
if (person) { if (person) {
@@ -613,24 +623,43 @@ export class LeadsService {
}); });
if (!stage) throw new NotFoundException('مرحله پیدا نشد'); 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({ const row = await this.prisma.lead.update({
where: { id }, where: { id },
data: { data: {
stageId, stageId,
position: position ?? 0, position: nextPosition,
...(stage.isConverted
? {}
: {}),
}, },
include: leadInclude, include: leadInclude,
}); });
await this.addEvent( if (placedIds) {
actor, await writePositions(
id, (ops) => this.prisma.$transaction(ops),
'stage_changed', placedIds,
`به مرحله «${stage.name}» منتقل شد`, (leadId, leadPosition) =>
); this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
);
}
if (before.stageId !== stageId) { if (before.stageId !== stageId) {
await this.addEvent(
actor,
id,
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${stageId}`, eventKey: `${id}:${stageId}`,
facts: { stageName: stage.name, source: row.source }, facts: { stageName: stage.name, source: row.source },
@@ -946,10 +975,7 @@ export class LeadsService {
} }
let person = await this.prisma.person.findFirst({ let person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, lead.mobile),
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
}); });
let createdPerson = false; let createdPerson = false;
@@ -976,7 +1002,7 @@ export class LeadsService {
lastName: named.lastName, lastName: named.lastName,
gender: named.gender, gender: named.gender,
showGenderInName: named.showGenderInName, showGenderInName: named.showGenderInName,
mobile: lead.mobile, mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, 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( async createFromImportRow(
organizationId: string, organizationId: string,
createdById: string | null, createdById: string | null,
@@ -1192,10 +1307,7 @@ export class LeadsService {
} }
const existingPerson = await this.prisma.person.findFirst({ const existingPerson = await this.prisma.person.findFirst({
where: { where: personWithMobile(organizationId, mobile),
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true }, select: { id: true },
}); });
if (existingPerson) { if (existingPerson) {
+21
View File
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import { import {
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class UpdateOrgRoleDto { export class UpdateOrgRoleDto {
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class RolePermissionPatchItemDto { export class RolePermissionPatchItemDto {
@@ -32,6 +32,8 @@ type RoleRow = {
color: string; color: string;
permissions: string[]; permissions: string[];
dataAccessPolicy?: unknown; dataAccessPolicy?: unknown;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
systemKey: string | null; systemKey: string | null;
parentId: string | null; parentId: string | null;
createdAt: Date; createdAt: Date;
@@ -79,6 +81,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -108,6 +112,8 @@ export class OrgRolesService {
description: (dto.description ?? '').trim(), description: (dto.description ?? '').trim(),
color: dto.color ?? parent?.color ?? 'teal', color: dto.color ?? parent?.color ?? 'teal',
permissions, permissions,
treasuryAccountIds: dto.treasuryAccountIds ?? [],
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
parentId, parentId,
...(dto.dataAccessPolicy ...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) } ? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
@@ -135,6 +141,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -159,6 +167,12 @@ export class OrgRolesService {
if (dto.dataAccessPolicy !== undefined) { if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy); 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) { if (dto.parentId !== undefined) {
const nextParentId = const nextParentId =
@@ -426,6 +440,8 @@ export class OrgRolesService {
color: row.color, color: row.color,
permissions: sanitizePermissions(row.permissions), permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy), dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
treasuryAccountIds: row.treasuryAccountIds ?? [],
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
systemKey, systemKey,
parentId: row.parentId, parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, 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 { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive'; import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> { ): Promise<string | null> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const code = cleanCode(row.personCode); const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = mobileCore.length >= 8 ? mobile : null; const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim(); const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim(); const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' '); const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
} }
if (!found && validMobile) { if (!found && validMobile) {
found = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
} }
+23 -1
View File
@@ -27,6 +27,7 @@ import {
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = { const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } }, 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 status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position = let placedIds: string[] | null = null;
let position =
dto.position ?? dto.position ??
(( ((
await this.prisma.deal.aggregate({ await this.prisma.deal.aggregate({
@@ -387,6 +389,17 @@ export class DealsService {
_max: { position: true }, _max: { position: true },
}) })
)._max.position ?? -1) + 1; )._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({ const row = await this.prisma.deal.update({
where: { id }, where: { id },
@@ -402,6 +415,15 @@ export class DealsService {
include: dealInclude, 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) { if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({ await this.notifications.create({
organizationId, organizationId,
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth'; import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string, mobileRaw: string,
): Promise<PortalRequestOtpResponse> { ): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست'); throw new BadRequestException('شماره موبایل معتبر نیست');
} }
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string, codeRaw: string,
): Promise<PortalLoginResponse> { ): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim(); const code = codeRaw.trim();
if (!mobile || !code) { if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است'); throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() }, data: { consumedAt: new Date() },
}); });
let person = await this.prisma.person.findFirst({ let person = mobileKeys.length
where: { organizationId: org.id, mobile }, ? await this.prisma.person.findFirst({
orderBy: { createdAt: 'asc' }, where: {
}); organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) { if (!person) {
person = await this.prisma.person.create({ person = await this.prisma.person.create({
data: { data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } }, where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string; organizationId: string;
role: string; role: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyName?: string;
}; };
}>(); }>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId, organizationId: auth.organizationId,
role: auth.role, role: auth.role,
apiKeyId: auth.apiKeyId, apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
}; };
return true; return true;
} }
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate( async authenticate(
rawKey: string, rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> { ): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim(); const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) { if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است'); throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key); const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({ const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null }, where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true }, select: { id: true, organizationId: true, name: true },
}); });
if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return { return {
organizationId: row.organizationId, organizationId: row.organizationId,
apiKeyId: row.id, apiKeyId: row.id,
name: row.name,
role: 'owner', role: 'owner',
}; };
} }
@@ -1,18 +1,55 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpException,
HttpStatus,
Param, Param,
Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; 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 { class ListQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1') @Controller('public/v1')
@UseGuards(ApiKeyAuthGuard) @UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller { 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') @Get('people')
async listPeople( async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service'; import { WebhooksService } from './webhooks.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService], exports: [ApiKeysService, WebhooksService],
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink), entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore, reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive, isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule), recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total, executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> { async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope); const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({ const row = await this.prisma.routine.create({
data: { data: {
...data, ...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> { async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
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.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput, data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> { async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto, dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> { ): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); 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); const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین'); await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> { async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id); const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src); this.assertCanWriteRoutine(scope, src);
const { const {
id: _id, id: _id,
createdAt: _c, createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) { async remove(actor: Actor, id: string) {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({ await this.prisma.routine.update({
where: { id }, where: { id },
data: { deletedAt: new Date(), isActive: false }, data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row; 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( private assertCanManageRoutine(
scope: RoutineScope, scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, 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 { 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 type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
/**
* حقوق فقط با treasury_payroll.
* بقیه مسیرها با treasury باز می‌شوند؛ اگر بخش ریز انتخاب شده باشد فقط همان‌ها.
*/
@Injectable() @Injectable()
export class TreasuryAccessGuard implements CanActivate { export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} 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 req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const actor = req.user; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); 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; if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, 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) ? membership.orgRole.permissions.map(String)
: []; : [];
const allowed = payroll const permissions = permissionsForTreasury(
? permissions.includes('treasury_payroll') rolePermissions,
: permissions.includes('treasury') || permissions.includes('treasury_payroll'); parseUserTreasuryAccess(membership?.treasuryAccessOverride),
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.'); );
const url = req.originalUrl || req.url || '';
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
return true; 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 { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; 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 data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook(); const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی'); const sheet = book.addWorksheet('گزارش مالی');
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
sheet.addRow([]); sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); 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(); const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer); 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) { private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({ const docs = await this.prisma.financialDocument.findMany({
where: { 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 { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body); 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') @Get('accounts')
accounts( accounts(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -61,7 +75,18 @@ export class TreasuryController {
updateAccount( updateAccount(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@Param('id') id: string, @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); return this.treasury.updateAccount(user, id, body);
} }
@@ -81,6 +106,29 @@ export class TreasuryController {
return this.treasury.transfer(user, body); 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') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -221,7 +269,11 @@ export class TreasuryController {
@Query('from') from?: string, @Query('from') from?: string,
@Query('to') to?: 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, { return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"', disposition: 'attachment; filename="treasury-report.xlsx"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard'; import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController], controllers: [TreasuryController],
providers: [ providers: [
TreasuryService, TreasuryService,
+455 -36
View File
@@ -1,8 +1,10 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
@@ -11,8 +13,14 @@ import type {
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialMovementDto, FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto, TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared'; } 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 { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -63,6 +71,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService, private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService, private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {} ) {}
async ensureCategories(organizationId: string) { async ensureCategories(organizationId: string) {
@@ -110,27 +120,80 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> { ): Promise<FinancialCategoryDto> {
const name = input.name.trim(); const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
const row = await this.prisma.financialCategory.create({ try {
data: { const row = await this.prisma.financialCategory.create({
organizationId: actor.organizationId, data: {
name, organizationId: actor.organizationId,
kind: input.kind, 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 { if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
id: row.id, const name = input.name == null ? current.name : input.name.trim();
name: row.name, if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
kind: row.kind as FinancialCategoryDto['kind'], try {
isActive: row.isActive, 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( async listAccounts(
actor: Actor, actor: Actor,
range?: { from?: string; to?: string }, range?: { from?: string; to?: string },
): Promise<FinancialAccountDto[]> { ): Promise<FinancialAccountDto[]> {
const scope = await this.accountScope(actor);
const accounts = await this.prisma.financialAccount.findMany({ const accounts = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId }, where: {
organizationId: actor.organizationId,
...(scope ? { id: { in: scope } } : {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }], orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
}); });
return Promise.all(accounts.map((account) => this.toAccountDto(account, range))); return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
@@ -196,19 +259,89 @@ export class TreasuryService {
async updateAccount( async updateAccount(
actor: Actor, actor: Actor,
id: string, 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> { ): Promise<FinancialAccountDto> {
const existing = await this.requireAccount(actor.organizationId, id); const existing = await this.requireAccount(actor.organizationId, id, actor);
const row = await this.prisma.financialAccount.update({ if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
where: { id: existing.id }, throw new BadRequestException('نوع حساب نامعتبر است.');
data: { }
...(input.name?.trim() ? { name: input.name.trim() } : {}), const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}), if (opening?.isNegative()) {
...(input.responsibleName !== undefined throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.');
? { responsibleName: input.responsibleName?.trim() || null } }
: {}), const openingDate =
...(input.isActive !== undefined ? { isActive: input.isActive } : {}), 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); return this.toAccountDto(row);
} }
@@ -218,7 +351,7 @@ export class TreasuryService {
accountId: string, accountId: string,
range?: { from?: string; to?: string }, range?: { from?: string; to?: string },
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> { ): 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 from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
@@ -303,8 +436,8 @@ export class TreasuryService {
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.'); if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
} }
const [source, target] = await Promise.all([ const [source, target] = await Promise.all([
this.requireAccount(actor.organizationId, input.fromAccountId), this.requireAccount(actor.organizationId, input.fromAccountId, actor),
this.requireAccount(actor.organizationId, input.toAccountId), this.requireAccount(actor.organizationId, input.toAccountId, actor),
]); ]);
if (!source.isActive || !target.isActive) { if (!source.isActive || !target.isActive) {
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.'); throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
@@ -354,10 +487,12 @@ export class TreasuryService {
kind: 'income' | 'expense', kind: 'income' | 'expense',
q?: string, q?: string,
): Promise<FinancialDocumentDto[]> { ): Promise<FinancialDocumentDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({ const rows = await this.prisma.financialDocument.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
kind, kind,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
...(q?.trim() ...(q?.trim()
? { ? {
OR: [ OR: [
@@ -384,7 +519,10 @@ export class TreasuryService {
bookedAt?: string; bookedAt?: string;
dueAt?: string | null; dueAt?: string | null;
categoryId?: string | null; categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null; personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null; partyName?: string | null;
method?: string | null; method?: string | null;
reference?: string | null; reference?: string | null;
@@ -398,9 +536,13 @@ export class TreasuryService {
const amount = dec(input.amount); const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === '' const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0) ? new Prisma.Decimal(0)
: dec(input.settleAmount); : dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) { if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
} }
@@ -408,7 +550,7 @@ export class TreasuryService {
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.'); throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
} }
if (input.accountId) { 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 (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (input.kind === 'expense' && settle.greaterThan(0)) { if (input.kind === 'expense' && settle.greaterThan(0)) {
const balance = await this.accountBalance(account.id); const balance = await this.accountBalance(account.id);
@@ -419,6 +561,13 @@ export class TreasuryService {
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => { 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({ const created = await tx.financialDocument.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -428,12 +577,15 @@ export class TreasuryService {
bookedAt, bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null, dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null, categoryId: input.categoryId || null,
personId: input.personId || null, personId: party.personId,
partyName: input.partyName?.trim() || null, companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null, accountId: input.accountId || null,
method: input.method || null, method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null, reference,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
@@ -449,7 +601,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment', kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title, memo: title,
reference: input.reference?.trim() || null, reference,
createdById: actor.userId, createdById: actor.userId,
}, },
}); });
@@ -462,6 +614,221 @@ export class TreasuryService {
return this.toDocumentDto(doc); 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( async settleDocument(
actor: Actor, actor: Actor,
id: string, id: string,
@@ -482,7 +849,7 @@ export class TreasuryService {
if (amount.greaterThan(remaining)) { if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده سند بیشتر است.'); 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 (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (doc.kind === 'expense') { if (doc.kind === 'expense') {
const balance = await this.accountBalance(account.id); const balance = await this.accountBalance(account.id);
@@ -644,9 +1011,14 @@ export class TreasuryService {
): Promise<TreasuryDashboardDto> { ): Promise<TreasuryDashboardDto> {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const scope = await this.accountScope(actor);
const accountFilter = scope
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
: {};
const bookedWhere = { const bookedWhere = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
status: { not: 'void' }, status: { not: 'void' },
...accountFilter,
...(from || to ...(from || to
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } ? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}), : {}),
@@ -654,6 +1026,7 @@ export class TreasuryService {
const cashWhere = { const cashWhere = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
voidedAt: null, voidedAt: null,
...(scope ? { accountId: { in: scope } } : {}),
...(from || to ...(from || to
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } ? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}), : {}),
@@ -680,12 +1053,13 @@ export class TreasuryService {
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
status: { in: ['open', 'partial'] }, status: { in: ['open', 'partial'] },
...accountFilter,
}, },
include: { movements: true }, include: { movements: true },
}), }),
this.listAccounts(actor), this.listAccounts(actor),
this.prisma.financialDocument.findMany({ this.prisma.financialDocument.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId, ...accountFilter },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, 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({ const row = await this.prisma.financialAccount.findFirst({
where: { id, organizationId }, where: { id, organizationId },
}); });
if (!row) throw new NotFoundException('حساب پیدا نشد.'); if (!row) throw new NotFoundException('حساب پیدا نشد.');
if (actor) {
const scope = await this.accountScope(actor);
if (scope && !scope.includes(row.id)) {
throw new ForbiddenException('به این حساب دسترسی ندارید.');
}
}
return row; return row;
} }
@@ -824,7 +1237,10 @@ export class TreasuryService {
bookedAt: Date; bookedAt: Date;
dueAt: Date | null; dueAt: Date | null;
categoryId: string | null; categoryId: string | null;
partyKind?: string | null;
personId: string | null; personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null; partyName: string | null;
accountId: string | null; accountId: string | null;
method: string | null; method: string | null;
@@ -851,7 +1267,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null, dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId, categoryId: row.categoryId,
categoryName: row.category?.name ?? null, categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId, personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName, partyName: row.partyName,
accountId: row.accountId, accountId: row.accountId,
accountName: row.account?.name ?? null, accountName: row.account?.name ?? null,
+6
View File
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
dataAccessOverride?: Record<string, unknown> | null; 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, UpdateUserVisibilityRequest,
UserRole, UserRole,
} from '@mento/shared'; } 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 { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope'; import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
@@ -47,6 +52,7 @@ type MembershipRow = {
dataVisibility?: string | null; dataVisibility?: string | null;
dataVisibilityScopes?: unknown; dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown; dataAccessOverride?: unknown;
treasuryAccessOverride?: unknown;
priorArchiveEnabled?: boolean; priorArchiveEnabled?: boolean;
logoutTime?: string | null; logoutTime?: string | null;
blockLoginAfterLogout?: boolean; blockLoginAfterLogout?: boolean;
@@ -370,23 +376,35 @@ export class UsersService {
organizationId: string, organizationId: string,
actorRole: UserRole, actorRole: UserRole,
targetId: string, targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null }, dto: {
dataAccessOverride?: Record<string, unknown> | null;
treasuryAccessOverride?: Record<string, unknown> | null;
},
): Promise<OrgUserDto> { ): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') { if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید'); throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
} }
if (dto.dataAccessOverride === undefined) { if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است'); throw new BadRequestException('تغییری ارسال نشده است');
} }
const target = await this.memberships.findUserInOrgById(organizationId, targetId); const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') { if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند'); throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
} }
const next = const data: Prisma.MembershipUpdateInput = {};
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride); 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({ await this.prisma.membership.update({
where: { id: target.id }, where: { id: target.id },
data: { dataAccessOverride: next }, data,
}); });
return this.dtoOf(organizationId, targetId); return this.dtoOf(organizationId, targetId);
} }
@@ -606,6 +624,10 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin' row.role === 'owner' || row.role === 'admin'
? {} ? {}
: parseDataAccessPolicy(row.dataAccessOverride), : parseDataAccessPolicy(row.dataAccessOverride),
treasuryAccessOverride:
row.role === 'owner' || row.role === 'admin'
? null
: parseUserTreasuryAccess(row.treasuryAccessOverride),
...(() => { ...(() => {
const effectiveDataAccess = resolveAllSectionAccess({ const effectiveDataAccess = resolveAllSectionAccess({
role: row.role, role: row.role,
@@ -3,13 +3,16 @@ import { computed, onMounted, ref, watch } from 'vue';
import type { import type {
DataAccessPolicy, DataAccessPolicy,
DataAccessSection, DataAccessSection,
FinancialAccountDto,
PersonImportSourceStatDto, PersonImportSourceStatDto,
RolePermissionKey, RolePermissionKey,
SectionDataAccess, SectionDataAccess,
UserTreasuryAccess,
} from '@mento/shared'; } from '@mento/shared';
import { import {
DATA_ACCESS_SECTION_LABELS, DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS, DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
emptySectionDataAccess, emptySectionDataAccess,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -21,11 +24,16 @@ const props = defineProps<{
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */ /** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[]; enabledPermissions?: RolePermissionKey[];
disabled?: boolean; disabled?: boolean;
/** تب‌های عمق داده قفل شوند؛ تب مدیریت مالی جدا می‌ماند */
dataLocked?: boolean;
showTreasury?: boolean;
treasuryAccess?: UserTreasuryAccess | null;
compact?: boolean; compact?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy]; 'update:modelValue': [DataAccessPolicy];
'update:treasuryAccess': [UserTreasuryAccess | null];
}>(); }>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */ /** بخش‌هایی که به 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( watch(
visibleSections, visibleSections,
(list) => { (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 }, { immediate: true },
); );
@@ -89,6 +98,48 @@ const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]); const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]); const projectStages = ref<IdOption[]>([]);
const routineCategories = 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() { async function loadOptions() {
await loadCustomerGroups().catch(() => undefined); await loadCustomerGroups().catch(() => undefined);
@@ -176,8 +227,13 @@ async function loadOptions() {
onMounted(() => { onMounted(() => {
void loadOptions(); void loadOptions();
void loadTreasuryAccounts();
}); });
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
const stagesForActive = computed(() => { const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value; if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value; if (active.value === 'sales') return dealStages.value;
@@ -218,11 +274,94 @@ const typesForActive = computed(() => {
> >
{{ DATA_ACCESS_SECTION_LABELS[s] }} {{ DATA_ACCESS_SECTION_LABELS[s] }}
</button> </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> </div>
<DataAccessEditor <DataAccessEditor
v-else
:key="active" :key="active"
:section="active" :section="editorSection"
:model-value="sectionValue(active)" :model-value="sectionValue(editorSection)"
:groups="customerGroups" :groups="customerGroups"
:stages="stagesForActive" :stages="stagesForActive"
:pipelines="pipelines" :pipelines="pipelines"
@@ -231,9 +370,9 @@ const typesForActive = computed(() => {
:import-sources="importSources" :import-sources="importSources"
:import-sources-ready="importSourcesReady" :import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed" :import-sources-failed="importSourcesFailed"
:disabled="disabled" :disabled="disabled || dataLocked"
:compact="compact" :compact="compact"
@update:model-value="setSection(active, $event)" @update:model-value="setSection(editorSection, $event)"
/> />
</template> </template>
</div> </div>
@@ -268,4 +407,17 @@ const typesForActive = computed(() => {
color: #64748b; color: #64748b;
font-size: 0.85rem; 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> </style>
+12 -4
View File
@@ -126,6 +126,7 @@ defineExpose({ refresh: load });
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button> <button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div> </div>
<Teleport to="body">
<div v-if="chargeOpen" class="layer"> <div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" /> <div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک"> <div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
@@ -170,7 +171,9 @@ defineExpose({ refresh: load });
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p> <p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</div> </div>
</div> </div>
</Teleport>
<Teleport to="body">
<div v-if="warnOpen" class="layer"> <div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" /> <div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی"> <div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
@@ -186,6 +189,7 @@ defineExpose({ refresh: load });
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button> <button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div> </div>
</div> </div>
</Teleport>
</div> </div>
</template> </template>
@@ -275,9 +279,12 @@ defineExpose({ refresh: load });
.layer { .layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 80; z-index: 120;
display: grid; display: flex;
place-items: center; align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
} }
.backdrop { .backdrop {
@@ -289,7 +296,8 @@ defineExpose({ refresh: load });
.modal { .modal {
position: relative; position: relative;
width: min(420px, calc(100vw - 2rem)); width: min(420px, calc(100vw - 2rem));
max-height: min(90vh, 640px); max-height: calc(100vh - 2rem);
margin: auto;
overflow: auto; overflow: auto;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
+12 -3
View File
@@ -11,6 +11,7 @@ import type {
TeamDto, TeamDto,
TeamMemberRole, TeamMemberRole,
UpdatePriorArchiveCutoffRequest, UpdatePriorArchiveCutoffRequest,
UserTreasuryAccess,
} from '@mento/shared'; } from '@mento/shared';
import { import {
DATA_ACCESS_SECTION_LABELS, 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 expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({}); const draftAccess = ref<DataAccessPolicy>({});
const draftTreasury = ref<UserTreasuryAccess | null>(null);
const accessMode = ref<'inherit' | 'custom'>('inherit'); const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false); const accessSaving = ref(false);
@@ -323,6 +329,7 @@ function openAccessEditor(u: OrgUserDto) {
draftAccess.value = hasOverride draftAccess.value = hasOverride
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) } ? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess); : clonePolicy(u.effectiveDataAccess);
draftTreasury.value = u.treasuryAccessOverride ?? null;
expandedAccess.value = u.id; expandedAccess.value = u.id;
} }
@@ -333,8 +340,8 @@ async function saveUserAccess(userId: string) {
try { try {
const payload = const payload =
accessMode.value === 'inherit' accessMode.value === 'inherit'
? { dataAccessOverride: null } ? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
: { dataAccessOverride: draftAccess.value }; : { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
await api.patch(`/users/${userId}/data-access`, payload); await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.'; ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null; expandedAccess.value = null;
@@ -528,8 +535,10 @@ onMounted(() => {
سفارشی برای این کاربر سفارشی برای این کاربر
</label> </label>
<DataAccessPolicyPanel <DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess" v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact compact
/> />
<div class="actions"> <div class="actions">
@@ -120,6 +120,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
assigneeNames, assigneeNames,
ownerId: item.assignee?.id ?? null, ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id), 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) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId]!;
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; 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 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 = { const prevFrom = from.items;
fromItems: from.items, const prevTo = to.items;
fromTotal: from.total, const prevFromTotal = from.total;
toItems: to.items, const prevToTotal = to.total;
toTotal: to.total,
};
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId }; const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId); const nextTo = [...without];
from.total = Math.max(0, from.total - 1); nextTo.splice(insertAt, 0, optimistic);
const nextTo = [...to.items]; if (same) {
const at = index == null || index < 0 || index > nextTo.length ? 0 : index; from.items = nextTo;
nextTo.splice(at, 0, optimistic); } else {
to.items = nextTo; from.items = from.items.filter((i) => i.id !== activityId);
to.total += 1; from.total = Math.max(0, from.total - 1);
to.items = nextTo;
to.total += 1;
}
moving.value = true; moving.value = true;
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, 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'); emit('changed');
} catch (e) { } catch (e) {
from.items = snapshot.fromItems; from.items = prevFrom;
from.total = snapshot.fromTotal; from.total = prevFromTotal;
to.items = snapshot.toItems; if (!same) {
to.total = snapshot.toTotal; to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
moving.value = false; moving.value = false;
+22 -2
View File
@@ -4,6 +4,7 @@ import {
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
FlagFilled,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { import {
@@ -13,10 +14,12 @@ import {
Flex as AFlex, Flex as AFlex,
Space as ASpace, Space as ASpace,
Tag as ATag, Tag as ATag,
Tooltip as ATooltip,
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars'; import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } 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), Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
); );
const showTrash = computed(() => props.selectMode && props.selected); 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(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', 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, margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer, background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary, borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary, boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
})); }));
</script> </script>
@@ -194,6 +209,11 @@ const cardStyle = computed(() => ({
{{ money.unit }} {{ money.unit }}
</a-typography-text> </a-typography-text>
</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-flex> </a-flex>
</a-card> </a-card>
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue'; import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue'; import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; 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( const props = withDefaults(
defineProps<{ defineProps<{
@@ -51,6 +51,8 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false); const renaming = ref(false);
const draft = ref(''); const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null); const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
margin: 0, margin: 0,
})); }));
function onCardDragOver(stageId: string, event: DragEvent) { function trackDrag(event: DragEvent, index: number | null) {
event.preventDefault(); 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(); event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, 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) { function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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() { function startRename() {
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
<template> <template>
<section <section
:style="columnStyle" :style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)" @dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)" @drop.prevent="onColumnDrop"
> >
<div :style="headStyle"> <div :style="headStyle">
<span <span
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
</a-dropdown> </a-dropdown>
</div> </div>
<div role="list" :aria-label="stage.title" :style="bodyStyle"> <div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div <div
v-for="(deal, index) in stage.deals" v-for="(deal, index) in stage.deals"
:key="deal.id" :key="deal.id"
@dragover="onCardDragOver(stage.id, $event)" @dragover="onCardDragOver(stage.id, index, $event)"
@drop="onCardDrop(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 <DealCard
:deal="deal" :deal="deal"
:accent="stage.color" :accent="stage.color"
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
:select-mode="selectMode" :select-mode="selectMode"
:selected="selectedIds.has(deal.id)" :selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')" @drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)" @open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)" @menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
/> />
</div> </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 <a-typography-text
v-if="!stage.deals.length" v-if="!stage.deals.length"
type="secondary" type="secondary"
@@ -2,31 +2,30 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue'; import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const canPayroll = computed( const links = computed(() => {
() => const role = auth.user?.role;
auth.user?.role === 'owner' || const permissions = auth.user?.permissions;
auth.user?.role === 'admin' || const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
Boolean(auth.user?.permissions?.includes('treasury_payroll')), { value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
); { value: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
const links = computed(() => [ { value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ value: '/finance', label: 'داشبورد', exact: true }, { value: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها' }, { value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' }, { value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات' }, { value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها' }, { value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []), { value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
{ value: '/finance/recurring', label: 'تکرارشونده' }, ];
{ value: '/finance/reports', label: 'گزارش' }, return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
{ value: '/finance/audit', label: 'سوابق' }, });
{ value: '/finance/categories', label: 'دسته‌بندی‌ها' },
]);
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label }))); 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; to?: string;
}) { }) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false; if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
const canTreasury = auth.user?.role === 'owner' const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin' || auth.user?.role === 'admin'
|| Boolean(auth.user?.permissions?.includes('treasury')) || perms.includes('treasury')
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')); || perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) return canTreasury; if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
}
const section = card.to ? sectionPermissionForPath(card.to) : null; const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section const hasSection = section
? hasSectionPermission(auth.user?.role, auth.user?.permissions, 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 { 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)); return asFile(JSON.parse(value));
} catch { } catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim(); 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 }; if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null; return null;
} }
+51 -5
View File
@@ -1,5 +1,6 @@
import { currencySuffix } from '@/lib/money'; import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search'; import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate'; export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -21,6 +22,8 @@ export type PipelineDeal = {
assigneeIds?: string[]; assigneeIds?: string[];
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */ /** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>; searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
}; };
export type PipelineUiCopy = { export type PipelineUiCopy = {
@@ -344,18 +347,16 @@ export function updateDealStage(
return without.map((stage) => { return without.map((stage) => {
if (stage.id !== stageId) return stage; if (stage.id !== stageId) return stage;
const next = [...stage.deals]; const next = [...stage.deals];
let insertAt = let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex;
toIndex == null || toIndex < 0 || toIndex > next.length
? next.length
: toIndex;
if ( if (
fromStageId === stageId && fromStageId === stageId &&
fromIndex >= 0 && fromIndex >= 0 &&
toIndex != null && toIndex != null &&
toIndex > fromIndex 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 }); next.splice(insertAt, 0, { ...moving!, stageId });
return { ...stage, deals: next }; 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( export function moveStageToIndex(
stages: PipelineStage[], stages: PipelineStage[],
stageId: string, 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 */ /** مسیر صفحه ← مجوز بخش؛ هم‌تراز SectionPermissionGuard در API */
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [ const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
@@ -39,6 +44,31 @@ export function hasSectionPermission(
return Boolean(permissions?.includes(key)); 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( export function canOpenPath(
role: UserRole | string | null | undefined, role: UserRole | string | null | undefined,
permissions: readonly 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 type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-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({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -330,7 +330,7 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد', treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.', treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
}, },
}, },
{ {
@@ -369,8 +369,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی', treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
}, },
}, },
], ],
@@ -678,10 +678,16 @@ router.beforeEach((to) => {
return true; return true;
} }
if (String(to.path).startsWith('/finance')) { if (String(to.path).startsWith('/finance')) {
const perms = auth.user?.permissions ?? []; if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
const payroll = to.path.startsWith('/finance/payroll'); return true;
if (payroll && perms.includes('treasury_payroll')) return true; }
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) 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' }; return { name: 'home' };
} }
+173 -17
View File
@@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access'; import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import {
leadNoteFileIsImage,
noteChips,
parseLeadFormNotes,
type LeadNoteFile,
type LeadNoteRow,
} from '@/lib/lead-form-notes';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -62,6 +69,51 @@ const moreOpen = ref(false);
const commentOpen = ref(false); const commentOpen = ref(false);
const postponeOpen = ref(false); const postponeOpen = ref(false);
const noteOpen = ref(true); 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 users = ref<OrgUserDto[]>([]);
const assignUserIds = ref<string[]>([]); const assignUserIds = ref<string[]>([]);
@@ -565,8 +617,61 @@ onMounted(() => {
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" /> <UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button> </button>
<div v-show="noteOpen"> <div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p> <dl v-if="noteRows.length" class="note-rows">
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p> <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"> <div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a <a
v-for="image in imageUrls.filter((item) => item.kind === 'image')" v-for="image in imageUrls.filter((item) => item.kind === 'image')"
@@ -654,7 +759,13 @@ onMounted(() => {
margin: 0; margin: 0;
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
padding-bottom: 5.5rem; padding-bottom: 1rem;
overflow-x: clip;
}
.page > * {
min-width: 0;
max-width: 100%;
} }
.nav { .nav {
@@ -876,6 +987,8 @@ dt {
dd { dd {
font-weight: 700; font-weight: 700;
text-align: start; text-align: start;
min-width: 0;
overflow-wrap: anywhere;
} }
.lead-link { .lead-link {
@@ -908,17 +1021,27 @@ dd {
.assignees-row dd { .assignees-row dd {
flex: 1 1 16rem; flex: 1 1 16rem;
min-width: 0; min-width: 0;
max-width: 100%;
font-weight: 500; 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, .flag.low,
.priority.low { .priority.low {
color: #64748b; color: #16a34a;
} }
.flag.medium, .flag.medium,
.priority.medium { .priority.medium {
color: #ea580c; color: #eab308;
} }
.flag.high, .flag.high,
@@ -968,12 +1091,53 @@ dd {
line-height: 1.9; line-height: 1.9;
font-size: 0.9rem; font-size: 0.9rem;
white-space: pre-line; white-space: pre-line;
overflow-wrap: anywhere;
} }
.note p.empty { .note p.empty {
color: #94a3b8; 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 { .note-photos {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1017,13 +1181,11 @@ dd {
} }
.dock { .dock {
position: fixed; position: static;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
display: grid; display: grid;
grid-template-columns: 1.15fr 1fr 1fr; grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem; gap: 0.5rem;
z-index: 8; width: 100%;
} }
.act { .act {
@@ -1053,15 +1215,9 @@ dd {
cursor: not-allowed; cursor: not-allowed;
} }
@media (min-width: 900px) { @media (max-width: 899px) {
.page { .page {
padding-bottom: 0; padding-bottom: calc(1rem + var(--mobile-tabbar-h));
}
.dock {
position: sticky;
inset-inline: auto;
bottom: 0;
} }
} }
</style> </style>
+2 -2
View File
@@ -2257,11 +2257,11 @@ watch([id, isNew], () => void load(), { immediate: true });
} }
.pri.medium { .pri.medium {
color: #0d9488; color: #eab308;
} }
.pri.low { .pri.low {
color: #2563eb; color: #16a34a;
} }
.pri.very-low { .pri.very-low {
+2 -2
View File
@@ -27,7 +27,7 @@ const props = withDefaults(
}>(), }>(),
{ embedded: false }, { embedded: false },
); );
const emit = defineEmits<{ saved: []; cancel: [] }>(); const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -152,7 +152,7 @@ async function submit() {
if (isNew.value) { if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload()); const { data } = await api.post<CompanyDto>('/companies', payload());
if (props.embedded) { if (props.embedded) {
emit('saved'); emit('saved', data);
return; return;
} }
await router.replace({ name: 'company-detail', params: { id: data.id } }); 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 { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared'; import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
@@ -61,6 +62,8 @@ const addUnqualified = ref(false);
const deleteTarget = ref<LeadStageDto | null>(null); const deleteTarget = ref<LeadStageDto | null>(null);
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null); const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
const draggingId = ref<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); const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */ /** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false; let suppressCardClick = false;
@@ -467,42 +470,84 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; 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() { function onDragEnd() {
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
clearDragUi();
window.setTimeout(() => { window.setTimeout(() => {
suppressCardClick = false; suppressCardClick = false;
}, 0); }, 0);
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
dropStageId.value = stageId; dropStageId.value = stageId;
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
if (index != null) insertAt.value = index;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; 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.preventDefault();
event.stopPropagation();
const leadId = const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || ''; event.dataTransfer?.getData('text/plain') || draggingId.value || '';
const at = index ?? insertAt.value ?? undefined;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
if (!leadId) return; clearDragUi();
if (!leadId || moving.value) return;
const fromCol = columns.value.find((c) => const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId), c.leads.some((l) => l.id === leadId),
); );
if (!fromCol || fromCol.stage.id === stageId) return; const target = columns.value.find((c) => c.stage.id === stageId);
if (!fromCol || !target) return;
const lead = fromCol.leads.find((l) => l.id === leadId); const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
const lead = fromCol.leads[fromIndex];
if (!lead) return; 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) => ({ const prev = columns.value.map((c) => ({
...c, ...c,
leads: [...c.leads], leads: [...c.leads],
})); }));
columns.value = columns.value.map((c) => { columns.value = columns.value.map((c) => {
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
if (c.stage.id === fromCol.stage.id) { if (c.stage.id === fromCol.stage.id) {
return { return {
...c, ...c,
@@ -511,11 +556,7 @@ async function onDrop(stageId: string, event: DragEvent) {
}; };
} }
if (c.stage.id === stageId) { if (c.stage.id === stageId) {
return { return { ...c, total: c.total + 1, leads: nextLeads };
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
} }
return c; return c;
}); });
@@ -523,7 +564,7 @@ async function onDrop(stageId: string, event: DragEvent) {
moving.value = true; moving.value = true;
error.value = ''; error.value = '';
try { try {
await api.patch(`/leads/${leadId}/move`, { stageId }); await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
} catch { } catch {
columns.value = prev; columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.'; error.value = 'جابه‌جایی لید ناموفق بود.';
@@ -543,7 +584,7 @@ onMounted(load);
مسیر لیدها مسیر لیدها
</a-typography-title> </a-typography-title>
<a-typography-text type="secondary"> <a-typography-text type="secondary">
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید. کشیدن کارت برای تغییر ستون یا جابه‌جایی داخل همان ستون. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text> </a-typography-text>
</div> </div>
<a-space :size="GAP"> <a-space :size="GAP">
@@ -646,20 +687,34 @@ onMounted(load);
</a-flex> </a-flex>
</div> </div>
<div :style="columnBodyStyle"> <div
<a-card :ref="(el) => setBodyEl(col.stage.id, el)"
v-for="lead in col.leads" :style="columnBodyStyle"
>
<div
v-for="(lead, index) in col.leads"
:key="lead.id" :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 hoverable
:bordered="true" :bordered="true"
:body-style="cardBodyStyle" :body-style="cardBodyStyle"
:style="{ :style="{
cursor: 'pointer', 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, flexShrink: 0,
margin: 0, margin: 0,
background: token.colorBgContainer, background: token.colorBgContainer,
boxShadow: token.boxShadow, boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
}" }"
@click="openLead(lead)" @click="openLead(lead)"
> >
@@ -789,6 +844,12 @@ onMounted(load);
</a-flex> </a-flex>
</a-flex> </a-flex>
</a-card> </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 <a-typography-text
v-if="!col.leads.length" 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; const dealId = raw;
if (!dealId || moving.value) return; if (!dealId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId)); const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1; if (current?.id === stageId && index == null) return;
if (current?.id === stageId && (index == null || index === currentIndex)) {
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); await applyUpdateDealStage(dealId, stageId, index);
} }
+4 -2
View File
@@ -189,14 +189,16 @@ onMounted(() => {
<code>X-Api-Key: mk_…</code> <code>X-Api-Key: mk_…</code>
یا یا
<code>Authorization: Bearer mk_…</code> <code>Authorization: Bearer mk_…</code>
— مسیرها: — خواندن:
<code>/api/public/v1/people</code> <code>/api/public/v1/people</code>
و و
<code>/api/public/v1/deals</code> <code>/api/public/v1/deals</code>
— ثبت لید از سایت بیرونی:
<code>POST /api/public/v1/leads</code>
</p> </p>
<div class="row"> <div class="row">
<UiField label="نام کلید"> <UiField label="نام کلید">
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" /> <UiInput v-model="newKeyName" placeholder="مثلاً نام سایت" />
</UiField> </UiField>
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton> <UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
</div> </div>
+131 -1
View File
@@ -10,7 +10,7 @@ import type {
UpdateTahesabConnectionRequest, UpdateTahesabConnectionRequest,
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { import {
UiBadge, UiBadge,
UiBusyOverlay, UiBusyOverlay,
@@ -74,6 +74,77 @@ const syncTablePageSize = ref(50);
const syncTablePage = ref(1); const syncTablePage = ref(1);
const syncOnlyNew = ref(false); 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(() => const syncVisibleResults = computed(() =>
syncOnlyNew.value syncOnlyNew.value
? syncResults.value.filter((r) => !r.alreadyLinked) ? syncResults.value.filter((r) => !r.alreadyLinked)
@@ -86,6 +157,7 @@ const syncPagedResults = computed(() => {
const start = (syncTablePage.value - 1) * syncTablePageSize.value; const start = (syncTablePage.value - 1) * syncTablePageSize.value;
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value); return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
}); });
watch(() => sync.connectionId, () => fillAutoSync());
watch([syncOnlyNew, syncTablePageSize], () => { watch([syncOnlyNew, syncTablePageSize], () => {
syncTablePage.value = 1; syncTablePage.value = 1;
}); });
@@ -178,6 +250,7 @@ async function load() {
if (!editingId.value) form.isDefault = data.length === 0; if (!editingId.value) form.isDefault = data.length === 0;
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id; if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id; if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
fillAutoSync();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String( ? String(
@@ -875,6 +948,63 @@ onMounted(load);
</UiSelect> </UiSelect>
</UiField> </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"> <div class="sync-sub-section">
<h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4> <h4 class="sync-subhead">۱) وارد کردن دسته‌ای با بازه کد مشتری</h4>
<p class="sync-sub-desc"> <p class="sync-sub-desc">
+105 -1
View File
@@ -14,6 +14,7 @@ import {
DATA_ACCESS_SECTIONS, DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS, ROLE_PERMISSION_ITEMS,
type DataAccessPolicy, type DataAccessPolicy,
type FinancialAccountDto,
type InviteUserRequest, type InviteUserRequest,
type OrganizationDto, type OrganizationDto,
type OrgModuleKey, type OrgModuleKey,
@@ -74,7 +75,16 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
conversations: ['conversations'], conversations: ['conversations'],
conversations_groups: ['conversations'], conversations_groups: ['conversations'],
treasury: ['treasury'], treasury: ['treasury'],
treasury_dashboard: ['treasury'],
treasury_incomes: ['treasury'],
treasury_expenses: ['treasury'],
treasury_receivables: ['treasury'],
treasury_accounts: ['treasury'],
treasury_payroll: ['treasury'], treasury_payroll: ['treasury'],
treasury_recurring: ['treasury'],
treasury_reports: ['treasury'],
treasury_audit: ['treasury'],
treasury_categories: ['treasury'],
behavior: ['behavior'], behavior: ['behavior'],
}; };
@@ -139,6 +149,10 @@ const roleFormDesc = ref('');
const roleFormColor = ref<RoleColor>('teal'); const roleFormColor = ref<RoleColor>('teal');
const roleFormParentId = ref(''); const roleFormParentId = ref('');
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key)); 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 deleteOpen = ref(false);
const deleteSaving = ref(false); const deleteSaving = ref(false);
@@ -616,7 +630,11 @@ function openCreateRole(parentId?: string | null) {
? [...parent.permissions] ? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key); : visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy); roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true; roleFormOpen.value = true;
void loadRoleAccounts();
} }
function openCreateChildRole() { function openCreateChildRole() {
@@ -636,7 +654,40 @@ function openEditRole() {
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? ''); roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions]; roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy); roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true; 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) { function permOn(key: RolePermissionKey) {
@@ -644,6 +695,14 @@ function permOn(key: RolePermissionKey) {
} }
function toggleFormPerm(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 = permOn(key)
? roleFormPerms.value.filter((p) => p !== key) ? roleFormPerms.value.filter((p) => p !== key)
: [...roleFormPerms.value, key]; : [...roleFormPerms.value, key];
@@ -667,12 +726,16 @@ async function submitRoleForm() {
permissions: RolePermissionKey[]; permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy']; dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
} = { } = {
name: roleFormName.value.trim(), name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(), description: roleFormDesc.value.trim(),
color: roleFormColor.value, color: roleFormColor.value,
permissions: roleFormPerms.value, permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value, dataAccessPolicy: roleFormDataAccess.value,
treasuryAccountIds: roleFormAccountIds.value,
treasuryOwnAccounts: roleFormOwnAccounts.value,
}; };
if (!editing?.systemKey) { if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null; body.parentId = roleFormParentId.value || null;
@@ -1491,11 +1554,48 @@ onUnmounted(() => {
</label> </label>
<fieldset class="perm-set"> <fieldset class="perm-set">
<legend>مجوزهای دسترسی</legend> <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)" /> <input type="checkbox" :checked="permOn(perm.key)" @change="toggleFormPerm(perm.key)" />
{{ perm.label }} {{ perm.label }}
</label> </label>
</fieldset> </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"> <fieldset class="perm-set">
<legend>محدوده داده</legend> <legend>محدوده داده</legend>
<DataAccessPolicyPanel <DataAccessPolicyPanel
@@ -2381,6 +2481,10 @@ textarea {
color: #0f172a; color: #0f172a;
} }
.perm-sub {
padding-inline-start: 1.4rem;
}
.sheet-handle, .sheet-handle,
.sheet-backdrop { .sheet-backdrop {
display: none; display: none;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared'; import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } 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 { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines'; import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); 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 id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value); const isEdit = computed(() => !!id.value);
@@ -60,6 +65,8 @@ const recurrence = ref<RecurrenceValue>({
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
const targetOptions = [ const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' }, { value: 'user' as const, label: 'یک کاربر' },
@@ -111,10 +118,14 @@ function fill(r: RoutineDto) {
} }
onMounted(async () => { onMounted(async () => {
if (!id.value) return; if (!id.value) {
if (selfId.value) form.assigneeId = selfId.value;
return;
}
loading.value = true; loading.value = true;
try { try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`); const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
loaded.value = data;
fill(data); fill(data);
} catch (e) { } catch (e) {
error.value = apiError(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 users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []); const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []); const categories = computed(() => shell.context.value?.categories ?? []);
@@ -169,6 +197,11 @@ const summary = computed(() => {
}); });
function validate(): string | null { 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.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.'; if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) 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); if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body); else await api.post('/routines', body);
shell.notifyChanged(); shell.notifyChanged();
void router.push('/routines/manage'); void router.push(backTo.value);
} catch (e) { } catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.'); error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally { } finally {
@@ -234,10 +267,11 @@ async function save() {
<UiPageHeader <UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'" :title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود." lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage" :back-to="backTo"
/> />
<div v-if="loading" class="state">در حال بارگذاری…</div> <div v-if="loading" class="state">در حال بارگذاری…</div>
<p v-else-if="foreign" class="error">{{ error }}</p>
<form v-else class="grid" @submit.prevent="save"> <form v-else class="grid" @submit.prevent="save">
<div class="col"> <div class="col">
@@ -268,7 +302,8 @@ async function save() {
<UiCard> <UiCard>
<h3 class="sec-title">مسئول</h3> <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" /> <UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required> <UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId"> <UiSelect v-model="form.assigneeId">
@@ -508,6 +543,13 @@ async function save() {
gap: var(--sp-2); gap: var(--sp-2);
} }
.self-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.error { .error {
margin: 0; margin: 0;
} }
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue'; import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineDto } from '@mento/shared'; import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui'; import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { import {
PRIORITY_LABELS, PRIORITY_LABELS,
@@ -16,6 +18,8 @@ import {
} from '@/lib/routines'; } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? '');
const items = ref<RoutineDto[]>([]); const items = ref<RoutineDto[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -48,7 +52,7 @@ const filtered = computed(() => {
<template> <template>
<div class="rlist"> <div class="rlist">
<div class="toolbar"> <div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p> <p class="lead">روتین‌های شما. کارهای هر روز از روی این‌ها ساخته می‌شوند. با «روتین جدید» می‌توانید برای خودتان برنامه تکراری بسازید.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" /> <UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div> </div>
@@ -58,8 +62,8 @@ const filtered = computed(() => {
<UiEmptyState <UiEmptyState
v-else-if="!filtered.length" v-else-if="!filtered.length"
icon="refresh" icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'" :title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما نیست'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند." description="با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده می‌شود."
/> />
<div v-else class="cards"> <div v-else class="cards">
@@ -90,6 +94,9 @@ const filtered = computed(() => {
<small class="since"> <small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }} از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small> </small>
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`" class="edit">
ویرایش
</RouterLink>
</footer> </footer>
</article> </article>
</div> </div>
@@ -239,6 +246,13 @@ const filtered = computed(() => {
font-size: var(--fs-xs); font-size: var(--fs-xs);
} }
.edit {
justify-self: start;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--accent);
}
.error { .error {
margin: 0; margin: 0;
} }
@@ -77,7 +77,7 @@ const tabs: Tab[] = [
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value)); 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( watch(
[() => route.name, context], [() => route.name, context],
@@ -106,7 +106,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها"> <RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" /> <UiIcon name="bell" :size="17" />
</RouterLink> </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> <template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید روتین جدید
</UiButton> </UiButton>
@@ -23,7 +23,7 @@ import {
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; 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 MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
@@ -35,12 +35,15 @@ const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const openForm = ref(false); const openForm = ref(false);
const editingId = ref<string | null>(null);
const responsibleKey = ref<string | undefined>(undefined);
const saving = ref(false); const saving = ref(false);
const transferOpen = ref(false); const transferOpen = ref(false);
const form = ref({ const emptyForm = () => ({
name: '', name: '',
type: 'cash' as FinancialAccountType, type: 'cash' as FinancialAccountType,
bankName: '', bankName: '',
@@ -50,6 +53,7 @@ const form = ref({
openingDate: '', openingDate: '',
notes: '', notes: '',
}); });
const form = ref(emptyForm());
const transfer = ref({ const transfer = ref({
fromAccountId: '', fromAccountId: '',
toAccountId: '', toAccountId: '',
@@ -80,9 +84,54 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.'; 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() { async function load() {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts'); const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data; 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() { function onFinishFailed() {
@@ -95,23 +144,21 @@ async function createAccount() {
ok.value = ''; ok.value = '';
try { try {
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0; const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
await api.post('/treasury/accounts', { const body = {
...form.value, ...form.value,
responsibleName: responsibleNameFromKey(),
openingBalance, openingBalance,
openingDate: form.value.openingDate || undefined, 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(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -193,12 +240,18 @@ onMounted(() => {
<a-card :bordered="false"> <a-card :bordered="false">
<a-space> <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-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space> </a-space>
<template v-if="openForm"> <template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider> <a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
<a-form <a-form
layout="vertical" layout="vertical"
:model="form" :model="form"
@@ -217,10 +270,15 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type" :rules="[{ required: true }]"> <a-form-item label="نوع" name="type" :rules="[{ required: true }]">
<a-select v-model:value="form.type" style="width: 100%"> <a-select
<a-select-option value="cash">صندوق نقدی</a-select-option> v-model:value="form.type"
<a-select-option value="bank">حساب بانکی</a-select-option> show-search
<a-select-option value="petty">تنخواه</a-select-option> 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-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -235,8 +293,24 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName"> <a-form-item label="مسئول">
<a-input v-model:value="form.responsibleName" /> <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-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -255,7 +329,9 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </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> </a-form>
</template> </template>
@@ -276,11 +352,18 @@ onMounted(() => {
> >
<a-select <a-select
v-model:value="transfer.fromAccountId" v-model:value="transfer.fromAccountId"
show-search
option-filter-prop="label"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب" 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 }} {{ account.name }}
</a-select-option> </a-select-option>
</a-select> </a-select>
@@ -294,6 +377,8 @@ onMounted(() => {
> >
<a-select <a-select
v-model:value="transfer.toAccountId" v-model:value="transfer.toAccountId"
show-search
option-filter-prop="label"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب" placeholder="انتخاب"
@@ -302,6 +387,7 @@ onMounted(() => {
v-for="account in active" v-for="account in active"
:key="`to-${account.id}`" :key="`to-${account.id}`"
:value="account.id" :value="account.id"
:label="account.name"
> >
{{ account.name }} {{ account.name }}
</a-select-option> </a-select-option>
@@ -339,6 +425,7 @@ onMounted(() => {
<a-list-item> <a-list-item>
<template #actions> <template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text> <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> <RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template> </template>
<a-list-item-meta> <a-list-item-meta>
@@ -347,7 +434,9 @@ onMounted(() => {
<a-tag v-if="!item.isActive">غیرفعال</a-tag> <a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template> </template>
<template #description> <template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات {{ typeLabel[item.type] }}
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
· موجودی اولیه {{ toman(item.openingBalance) }}
</template> </template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -13,20 +13,38 @@ import {
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList, List as AList,
Modal as AModal,
Popconfirm as APopconfirm,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Switch as ASwitch,
Tag as ATag, Tag as ATag,
Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared'; import type { FinancialCategoryDto } from '@mento/shared';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]); 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 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() { async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories'); const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
@@ -35,6 +53,7 @@ async function load() {
async function add() { async function add() {
error.value = ''; error.value = '';
saving.value = true;
try { try {
await api.post('/treasury/categories', { await api.post('/treasury/categories', {
name: form.value.name, name: form.value.name,
@@ -43,66 +62,160 @@ async function add() {
form.value.name = ''; form.value.name = '';
await load(); await load();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = apiMessage(e);
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.') } 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(() => { onMounted(() => {
void load(); void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
}); });
</script> </script>
<template> <template>
<a-flex vertical :gap="16"> <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-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider> <a-divider orientation="right">دسته جدید</a-divider>
<a-form layout="vertical" :model="form" @finish="add"> <a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="نام" label="نام"
name="name" name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]" :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-button type="link" danger>حذف</a-button>
</a-form-item> </a-popconfirm>
</a-col> </template>
<a-col :xs="24" :md="8"> <a-list-item-meta>
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]"> <template #title>{{ item.name }}</template>
<a-select v-model:value="form.kind" style="width: 100%"> </a-list-item-meta>
<a-select-option value="income">درآمد</a-select-option> </a-list-item>
<a-select-option value="expense">هزینه</a-select-option> </template>
</a-select> </a-list>
</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-divider orientation="right">فهرست</a-divider> <a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." /> <a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else item-layout="horizontal" :data-source="expenseRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'"> <a-tag v-if="!item.isActive">غیرفعال</a-tag>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} <a-button type="link" @click="openEdit(item)">ویرایش</a-button>
</a-tag> <a-popconfirm
</template> title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
<a-list-item-meta> ok-text="حذف"
<template #title>{{ item.name }}</template> cancel-text="انصراف"
</a-list-item-meta> :ok-button-props="{ danger: true }"
</a-list-item> @confirm="remove(item)"
</template> >
</a-list> <a-button type="link" danger>حذف</a-button>
</a-card> </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> </a-flex>
</template> </template>
@@ -19,8 +19,17 @@ import {
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; 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 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 { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
const kind = 'expense' as const; const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const error = ref(''); const error = ref('');
@@ -41,13 +51,14 @@ const form = ref({
title: '', title: '',
amount: '', amount: '',
categoryId: '', categoryId: '',
partyName: '', partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: '', accountId: '',
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
}); });
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده'; const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -71,13 +82,29 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; 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() { 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<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'), 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; rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
@@ -96,31 +123,44 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.'; error.value = 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; 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, kind,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, 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, accountId: form.value.accountId || null,
settleAmount, settleAmount,
method: form.value.method, method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null, notes: form.value.notes || null,
}); });
settleMirror.reset();
form.value = { form.value = {
title: '', title: '',
amount: '', amount: '',
categoryId: '', categoryId: '',
partyName: '', partyKind: undefined,
partyId: undefined,
accountId: '', accountId: '',
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
}; };
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -180,37 +220,37 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <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-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="بدون دسته" 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 }} {{ cat.name }}
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" /> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId"> <a-form-item label="حساب" name="accountId">
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب حساب" 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 }} {{ account.name }}
</a-select-option> </a-select-option>
</a-select> </a-select>
@@ -218,22 +258,20 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="روش" name="method"> <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"> <a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value" :label="opt.label">
{{ opt.label }} {{ opt.label }}
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </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-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداخت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
@@ -242,7 +280,7 @@ onMounted(() => {
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -268,11 +306,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -15,7 +15,6 @@ import {
Grid, Grid,
Input as AInput, Input as AInput,
List as AList, List as AList,
Modal as AModal,
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
Select as ASelect, Select as ASelect,
@@ -34,10 +33,7 @@ import {
CreditCardOutlined, CreditCardOutlined,
FileTextOutlined, FileTextOutlined,
InfoCircleOutlined, InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined, TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali'; import { formatLocalDateInput } from '@/lib/jalali';
@@ -45,10 +41,15 @@ import type {
FinancialAccountDto, FinancialAccountDto,
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto, TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.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 { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const; const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null); const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -70,9 +72,6 @@ const ok = ref('');
const saving = ref(false); const saving = ref(false);
const q = ref(''); const q = ref('');
const formTab = ref<'main' | 'extra'>('main'); const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>(); const mobileFormRef = ref<FormInstance>();
function emptyForm() { function emptyForm() {
@@ -80,17 +79,18 @@ function emptyForm() {
title: '', title: '',
amount: '', amount: '',
categoryId: undefined as string | undefined, categoryId: undefined as string | undefined,
partyName: '', partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined, accountId: undefined as string | undefined,
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
reference: '',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()), bookedAt: formatLocalDateInput(new Date()),
}; };
} }
const form = ref(emptyForm()); const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده'; const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; 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() { 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<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'), api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null), 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; rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data; if (dash) dashboard.value = dash.data;
} }
function resetForm() { function resetForm() {
settleMirror.reset();
form.value = emptyForm(); form.value = emptyForm();
formTab.value = 'main'; formTab.value = 'main';
error.value = ''; error.value = '';
@@ -167,22 +184,34 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.'; error.value = 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; 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, kind,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, 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, accountId: form.value.accountId || null,
settleAmount, settleAmount,
method: form.value.method, method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined, bookedAt: form.value.bookedAt || undefined,
}); });
resetForm(); resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(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(() => { onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.'; error.value = 'بارگذاری ناموفق بود.';
@@ -358,10 +363,8 @@ onMounted(() => {
<a-row :gutter="[12, 0]"> <a-row :gutter="[12, 0]">
<a-col :span="12"> <a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب"> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
@@ -372,11 +375,12 @@ onMounted(() => {
<span>روش دریافت</span> <span>روش دریافت</span>
</a-space> </a-space>
</template> </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 <a-select-option
v-for="opt in methodOptions" v-for="opt in methodOptions"
:key="opt.value" :key="opt.value"
:value="opt.value" :value="opt.value"
:label="opt.label"
> >
{{ opt.label }} {{ opt.label }}
</a-select-option> </a-select-option>
@@ -392,21 +396,16 @@ onMounted(() => {
<span>دسته‌بندی درآمد</span> <span>دسته‌بندی درآمد</span>
</a-space> </a-space>
</template> </template>
<a-flex :gap="8"> <a-select show-search option-filter-prop="label"
<a-select v-model:value="form.categoryId"
v-model:value="form.categoryId" allow-clear
allow-clear placeholder="انتخاب دسته"
placeholder="انتخاب دسته" style="width: 100%"
style="flex: 1; min-width: 0" >
> <a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id"> {{ cat.name }}
{{ cat.name }} </a-select-option>
</a-select-option> </a-select>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item> </a-form-item>
<a-row :gutter="[12, 0]"> <a-row :gutter="[12, 0]">
@@ -425,7 +424,12 @@ onMounted(() => {
name="settleAmount" name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]" :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-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -471,7 +475,7 @@ onMounted(() => {
<span>حساب مقصد</span> <span>حساب مقصد</span>
</a-space> </a-space>
</template> </template>
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
placeholder="انتخاب حساب" placeholder="انتخاب حساب"
@@ -481,6 +485,7 @@ onMounted(() => {
v-for="account in accounts" v-for="account in accounts"
:key="account.id" :key="account.id"
:value="account.id" :value="account.id"
:label="account.name"
> >
{{ account.name }} {{ account.name }}
</a-select-option> </a-select-option>
@@ -491,12 +496,6 @@ onMounted(() => {
</div> </div>
<div v-show="formTab === 'extra'"> <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-form-item label="توضیحات" name="notes">
<a-textarea <a-textarea
v-model:value="form.notes" v-model:value="form.notes"
@@ -509,13 +508,15 @@ onMounted(() => {
</a-card> </a-card>
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="جستجو"> <a-form-item label="جستجو">
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -538,11 +539,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -612,31 +609,31 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <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-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="بدون دسته" 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 }} {{ cat.name }}
</a-select-option> </a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName"> <a-form-item label="طرف حساب" name="partyKind">
<a-input v-model:value="form.partyName" /> <TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId"> <a-form-item label="حساب" name="accountId">
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
@@ -646,6 +643,7 @@ onMounted(() => {
v-for="account in accounts" v-for="account in accounts"
:key="account.id" :key="account.id"
:value="account.id" :value="account.id"
:label="account.name"
> >
{{ account.name }} {{ account.name }}
</a-select-option> </a-select-option>
@@ -654,11 +652,12 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="روش" name="method"> <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 <a-select-option
v-for="opt in methodOptions" v-for="opt in methodOptions"
:key="opt.value" :key="opt.value"
:value="opt.value" :value="opt.value"
:label="opt.label"
> >
{{ opt.label }} {{ opt.label }}
</a-select-option> </a-select-option>
@@ -670,11 +669,6 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" /> <JalaliDateInput v-model="form.bookedAt" />
</a-form-item> </a-form-item>
</a-col> </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-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes"> <a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.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-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider> <a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical"> <a-form layout="vertical">
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
@@ -692,7 +688,7 @@ onMounted(() => {
<a-input <a-input
v-model:value="q" v-model:value="q"
allow-clear allow-clear
placeholder="جستجو…" placeholder="عنوان یا شماره پیگیری"
@press-enter="load" @press-enter="load"
@change="load" @change="load"
/> />
@@ -718,11 +714,7 @@ onMounted(() => {
</template> </template>
<a-list-item-meta> <a-list-item-meta>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description> <template #description>{{ docLine(item) }}</template>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta> </a-list-item-meta>
</a-list-item> </a-list-item>
</template> </template>
@@ -731,23 +723,5 @@ onMounted(() => {
</a-flex> </a-flex>
</template> </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> </div>
</template> </template>
@@ -166,13 +166,13 @@ onMounted(() => {
name="userId" name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]" :rules="[{ required: true, message: 'پرسنل الزامی است' }]"
> >
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.userId" v-model:value="form.userId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب" 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 }} {{ user.fullName }}
</a-select-option> </a-select-option>
</a-select> </a-select>
@@ -239,7 +239,7 @@ onMounted(() => {
name="accountId" name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]" :rules="[{ required: true, message: 'حساب الزامی است' }]"
> >
<a-select <a-select show-search option-filter-prop="label"
v-model:value="pay.accountId" v-model:value="pay.accountId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
@@ -249,6 +249,7 @@ onMounted(() => {
v-for="account in accounts" v-for="account in accounts"
:key="account.id" :key="account.id"
:value="account.id" :value="account.id"
:label="account.name"
> >
{{ account.name }} {{ account.name }}
</a-select-option> </a-select-option>
@@ -244,13 +244,13 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId"> <a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب" 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 }} {{ account.name }}
</a-select-option> </a-select-option>
</a-select> </a-select>
@@ -263,9 +263,9 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval"> <a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom"> <a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option> <a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option> <a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -329,7 +329,7 @@ onMounted(() => {
name="accountId" name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]" :rules="[{ required: true, message: 'حساب الزامی است' }]"
> >
<a-select <a-select show-search option-filter-prop="label"
v-model:value="collect.accountId" v-model:value="collect.accountId"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
@@ -339,6 +339,7 @@ onMounted(() => {
v-for="account in accounts" v-for="account in accounts"
:key="account.id" :key="account.id"
:value="account.id" :value="account.id"
:label="account.name"
> >
{{ account.name }} {{ account.name }}
</a-select-option> </a-select-option>
@@ -111,9 +111,9 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]"> <a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%"> <a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option> <a-select-option value="expense" label="هزینه">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option> <a-select-option value="income" label="درآمد">درآمد</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -128,10 +128,10 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]"> <a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
<a-select v-model:value="form.interval" style="width: 100%"> <a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option> <a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option> <a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
<a-select-option value="yearly">سالانه</a-select-option> <a-select-option value="yearly" label="سالانه">سالانه</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
@@ -9,16 +10,21 @@ import {
Flex as AFlex, Flex as AFlex,
List as AList, List as AList,
Table as ATable, Table as ATable,
Tabs as ATabs,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-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 { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane;
type Period = { type Period = {
bookedIncome: string; bookedIncome: string;
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
const previous = ref<Period | null>(null); const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]); const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: 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 error = ref('');
const balanceError = ref('');
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const lines = [ const lines = [
['درآمد ثبت‌شده', 'bookedIncome'], ['درآمد ثبت‌شده', 'bookedIncome'],
@@ -81,6 +97,50 @@ async function load() {
receivablesByParty.value = data.receivablesByParty; 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() { async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' }); const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data); const url = URL.createObjectURL(data);
@@ -95,6 +155,14 @@ onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.'; 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> </script>
@@ -108,9 +176,12 @@ onMounted(() => {
</a-flex> </a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" /> <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-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 <a-table
v-if="metricRows.length" v-if="metricRows.length"
:columns="metricColumns" :columns="metricColumns"
@@ -168,6 +239,39 @@ onMounted(() => {
</template> </template>
</a-list> </a-list>
</template> </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-card>
</a-flex> </a-flex>
</template> </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; dataAccessOverride?: DataAccessPolicy;
/**
* null = دسترسی مالی از نقش می‌آید.
* آبجکت = جایگزین بخش‌ها و حساب‌های مالی همین کاربر.
*/
treasuryAccessOverride?: UserTreasuryAccess | null;
/** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */ /** سیاست مؤثر پس از resolve (نقش + override) — فقط برای نمایش UI */
effectiveDataAccess?: DataAccessPolicy; effectiveDataAccess?: DataAccessPolicy;
/** @deprecated از effectiveDataAccess.customers استفاده کنید */ /** @deprecated از effectiveDataAccess.customers استفاده کنید */
@@ -596,6 +601,39 @@ export interface UpdateUserDataAccessRequest {
* آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک می‌شود). * آبجکت یعنی جایگزینی کامل override (کلید غایب در آبجکت = آن بخش از override پاک می‌شود).
*/ */
dataAccessOverride?: DataAccessPolicy | null; 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 { export function emptySectionDataAccess(): SectionDataAccess {
@@ -741,7 +779,16 @@ export type RolePermissionKey =
| "conversations" | "conversations"
| "conversations_groups" | "conversations_groups"
| "treasury" | "treasury"
| "treasury_dashboard"
| "treasury_incomes"
| "treasury_expenses"
| "treasury_receivables"
| "treasury_accounts"
| "treasury_payroll" | "treasury_payroll"
| "treasury_recurring"
| "treasury_reports"
| "treasury_audit"
| "treasury_categories"
| "behavior"; | "behavior";
/** بخشی که مسئولش را می‌شود به کاربر دیگری داد */ /** بخشی که مسئولش را می‌شود به کاربر دیگری داد */
@@ -787,10 +834,59 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "conversations", label: "گفتگو" }, { key: "conversations", label: "گفتگو" },
{ key: "conversations_groups", label: "ساخت گروه گفتگو" }, { key: "conversations_groups", label: "ساخت گروه گفتگو" },
{ key: "treasury", 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: "شخصیت‌شناسی و دستیار فروش" }, { 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 { export interface OrgRoleMemberDto {
id: string; id: string;
fullName: string; fullName: string;
@@ -816,6 +912,10 @@ export interface OrgRoleDto {
childCount: number; childCount: number;
userCount: number; userCount: number;
members: OrgRoleMemberDto[]; members: OrgRoleMemberDto[];
/** حساب‌های اضافی؛ خالی به‌تنهایی یعنی هنوز محدود نشده */
treasuryAccountIds: string[];
/** true یعنی اعضای نقش حساب‌هایی را می‌بینند که خودشان مسئول آن‌ها هستند */
treasuryOwnAccounts: boolean;
} }
export interface CreateOrgRoleRequest { export interface CreateOrgRoleRequest {
@@ -826,6 +926,10 @@ export interface CreateOrgRoleRequest {
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
/** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */ /** اگر ست شود، این نقش زیرمجموعهٔ نقش والد خواهد بود */
parentId?: string | null; parentId?: string | null;
/** حساب‌های اضافی، جدا از حساب‌هایی که خودش مسئول است */
treasuryAccountIds?: string[];
/** دسترسی به حساب‌هایی که نام خود عضو در مسئول ثبت شده */
treasuryOwnAccounts?: boolean;
} }
export interface UpdateOrgRoleRequest { export interface UpdateOrgRoleRequest {
@@ -836,6 +940,10 @@ export interface UpdateOrgRoleRequest {
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
/** null یعنی جدا کردن از والد و تبدیل به ریشه */ /** null یعنی جدا کردن از والد و تبدیل به ریشه */
parentId?: string | null; parentId?: string | null;
/** حساب‌های اضافی، جدا از حساب‌هایی که خودش مسئول است */
treasuryAccountIds?: string[];
/** دسترسی به حساب‌هایی که نام خود عضو در مسئول ثبت شده */
treasuryOwnAccounts?: boolean;
} }
export interface InviteUserRequest { export interface InviteUserRequest {
@@ -4179,6 +4287,11 @@ export interface TahesabConnectionDto {
lastHealthAt: string | null; lastHealthAt: string | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
/** ساعت‌های ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */
customerSyncHours: number[];
customerSyncLastRunAt: string | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -4193,7 +4306,11 @@ export interface CreateTahesabConnectionRequest {
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
} }
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>; export interface UpdateTahesabConnectionRequest
extends Partial<CreateTahesabConnectionRequest> {
/** آرایهٔ خالی بروزرسانی خودکار را خاموش می‌کند. */
customerSyncHours?: number[];
}
export interface PersonAccountingLinkDto { export interface PersonAccountingLinkDto {
id: string; id: string;
@@ -6153,6 +6270,8 @@ export interface RoutineDto {
entityLink: RoutineEntityLink; entityLink: RoutineEntityLink;
reminderMinutesBefore: number | null; reminderMinutesBefore: number | null;
isActive: boolean; isActive: boolean;
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش می‌کند */
createdById: string | null;
recurrenceLabel: string; recurrenceLabel: string;
/** درصد اجرا در ۳۰ روز اخیر */ /** درصد اجرا در ۳۰ روز اخیر */
executionRate: number | null; executionRate: number | null;
@@ -6353,6 +6472,7 @@ export interface RoutineUserSettingsDto {
export type FinancialAccountType = "bank" | "cash" | "petty"; export type FinancialAccountType = "bank" | "cash" | "petty";
export type FinancialDocKind = "income" | "expense"; export type FinancialDocKind = "income" | "expense";
export type FinancialDocStatus = "open" | "partial" | "settled" | "void"; 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 type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
export interface FinancialAccountDto { export interface FinancialAccountDto {
@@ -6391,7 +6511,10 @@ export interface FinancialDocumentDto {
dueAt: string | null; dueAt: string | null;
categoryId: string | null; categoryId: string | null;
categoryName: string | null; categoryName: string | null;
partyKind: FinancialPartyKind | null;
personId: string | null; personId: string | null;
companyId: string | null;
userId: string | null;
partyName: string | null; partyName: string | null;
accountId: string | null; accountId: string | null;
accountName: string | null; accountName: string | null;
@@ -6444,6 +6567,26 @@ export interface FinancialReceivableDto {
createdAt: string; 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 { export interface TreasuryDashboardDto {
bookedIncome: string; bookedIncome: string;
actualReceipts: string; actualReceipts: string;