Compare commits
58
Commits
b11affbdfd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de77066cb0 | ||
|
|
d23dd20237 | ||
|
|
8a4bd70ad4 | ||
|
|
d87321c7bf | ||
|
|
1a05eee46d | ||
|
|
ec9a7cf9c9 | ||
|
|
4f08ffae36 | ||
|
|
d7cb92bf81 | ||
|
|
871d7bba1a | ||
|
|
f13a1249af | ||
|
|
fdf1fbe4aa | ||
|
|
6df198321a | ||
|
|
4c8604d346 | ||
|
|
25a24772fc | ||
|
|
932dc3f1c1 | ||
|
|
5b26b9156d | ||
|
|
d12d7b91cb | ||
|
|
49459707cb | ||
|
|
ba7f942059 | ||
|
|
96105468d4 | ||
|
|
7a8007fff0 | ||
|
|
da851bd9e5 | ||
|
|
dae3ae3f7e | ||
|
|
b03d97dd9e | ||
|
|
8b8d01976d | ||
|
|
4764562bd4 | ||
|
|
0e1bc4a1fe | ||
|
|
3c9b906bd6 | ||
|
|
ef58922e13 | ||
|
|
06036b00d3 | ||
|
|
d33d88e76d | ||
|
|
5ef693990c | ||
|
|
6ebd45f2cb | ||
|
|
33f33f9521 | ||
|
|
97f00d7e6d | ||
|
|
b8c49293f7 | ||
|
|
f36711174e | ||
|
|
abb1a3a18e | ||
|
|
21d2c69227 | ||
|
|
0ca9540524 | ||
|
|
dc691c5192 | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 | ||
|
|
1ccbefa7a3 | ||
|
|
14f4e9d7d8 | ||
|
|
52edccf74e | ||
|
|
bffe174162 | ||
|
|
35d74d33ed |
@@ -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
|
||||
@@ -0,0 +1,129 @@
|
||||
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
|
||||
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
|
||||
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}"
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
|
||||
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
|
||||
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
|
||||
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
|
||||
@@ -33,6 +33,17 @@ enum ActivityStatus {
|
||||
canceled
|
||||
}
|
||||
|
||||
enum ActivityCompletionMode {
|
||||
any
|
||||
all
|
||||
}
|
||||
|
||||
/// چه کسانی میتوانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
|
||||
enum ActivityEditPolicy {
|
||||
creator
|
||||
assignees
|
||||
}
|
||||
|
||||
enum ActivityPriority {
|
||||
low
|
||||
medium
|
||||
@@ -295,6 +306,7 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||
@@ -353,6 +365,10 @@ model OrgRole {
|
||||
permissions String[] @default([])
|
||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||
dataAccessPolicy Json @default("{}")
|
||||
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
||||
treasuryAccountIds String[] @default([])
|
||||
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
||||
treasuryOwnAccounts Boolean @default(false)
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||
@@ -387,6 +403,8 @@ model Membership {
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||
dataAccessOverride Json @default("{}")
|
||||
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
||||
treasuryAccessOverride Json?
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
@@ -518,6 +536,7 @@ model Company {
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
financialDocuments FinancialDocument[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
@@ -875,6 +894,12 @@ model Activity {
|
||||
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک میشود
|
||||
reminderSentAt DateTime?
|
||||
completedAt DateTime?
|
||||
/// any = با انجام یکی بسته میشود؛ all = هر مسئول باید سهم خودش را بزند
|
||||
completionMode ActivityCompletionMode @default(any)
|
||||
/// مسئولانی که سهم خود را در حالت all ثبت کردهاند
|
||||
completedByIds String[] @default([])
|
||||
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
|
||||
editPolicy ActivityEditPolicy @default(creator)
|
||||
tags String[] @default([])
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
@@ -896,6 +921,8 @@ model Activity {
|
||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||
stageId String?
|
||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||
/// ترتیب کارت داخل همان ستون کانبان
|
||||
boardPosition Int @default(0)
|
||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||
typeDefId String?
|
||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||
@@ -911,6 +938,7 @@ model Activity {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([stageId, boardPosition])
|
||||
@@index([boardId])
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
@@ -1832,6 +1860,13 @@ model AccountingConnection {
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
||||
customerSyncHours Int[] @default([])
|
||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
||||
customerSyncLastSlot String?
|
||||
customerSyncLastRunAt DateTime?
|
||||
customerSyncLastSummary String?
|
||||
customerSyncLastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
personLinks PersonAccountingLink[]
|
||||
@@ -2321,6 +2356,12 @@ model FinancialDocument {
|
||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
userId String?
|
||||
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// person | company | user | other
|
||||
partyKind String?
|
||||
partyName String?
|
||||
accountId String?
|
||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||
@@ -2341,6 +2382,8 @@ model FinancialDocument {
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
@@ -2421,8 +2464,11 @@ model FinancialPayroll {
|
||||
period String
|
||||
baseAmount Decimal @db.Decimal(18, 2)
|
||||
benefits Decimal @default(0) @db.Decimal(18, 2)
|
||||
benefitsReason String?
|
||||
bonus Decimal @default(0) @db.Decimal(18, 2)
|
||||
bonusReason String?
|
||||
deductions Decimal @default(0) @db.Decimal(18, 2)
|
||||
deductionsReason String?
|
||||
payable Decimal @db.Decimal(18, 2)
|
||||
dueDate DateTime
|
||||
notes String?
|
||||
@@ -2927,18 +2973,28 @@ model MontifyWebhookDelivery {
|
||||
@@index([connectionId, createdAt])
|
||||
}
|
||||
|
||||
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی)
|
||||
/// اتصال تلفن سازمانی / سانترال (وبهوک عمومی یا ارائهدهنده)
|
||||
model VoipConnection {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
/// generic | daftareshoma
|
||||
provider String @default("generic")
|
||||
enabled Boolean @default(true)
|
||||
webhookSecretCiphertext String?
|
||||
webhookSecretIv String?
|
||||
webhookSecretTag String?
|
||||
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند
|
||||
/// قالب URL شروع تماس؛ جای {number} شماره مینشیند (generic)
|
||||
clickToCallUrl String?
|
||||
/// توکن Bearer API دفتر شما (رمزنگاریشده)
|
||||
apiTokenCiphertext String?
|
||||
apiTokenIv String?
|
||||
apiTokenTag String?
|
||||
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
|
||||
fromNumber String?
|
||||
/// خط/داخلی سازمان در دفتر شما (caller_extension)
|
||||
callerExtension String?
|
||||
lastEventAt DateTime?
|
||||
lastError String?
|
||||
calls VoipCallLog[]
|
||||
@@ -2946,6 +3002,7 @@ model VoipConnection {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, enabled])
|
||||
@@index([organizationId, provider])
|
||||
}
|
||||
|
||||
model VoipCallLog {
|
||||
@@ -2960,6 +3017,8 @@ model VoipCallLog {
|
||||
status String @default("completed")
|
||||
fromNumber String?
|
||||
toNumber String?
|
||||
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
|
||||
lineExtension String?
|
||||
personId String?
|
||||
activityId String?
|
||||
durationSec Int?
|
||||
@@ -2984,6 +3043,8 @@ model VoipCallLog {
|
||||
|
||||
@@unique([connectionId, externalCallId])
|
||||
@@index([organizationId, syncedAt])
|
||||
@@index([organizationId, lineExtension])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([activityId])
|
||||
@@index([summaryStatus, summaryNextAttemptAt])
|
||||
@@ -3760,3 +3821,9 @@ model AttendanceEvent {
|
||||
@@index([organizationId, userId, at])
|
||||
@@unique([organizationId, userId, kind, sessionStartedAt])
|
||||
}
|
||||
|
||||
/// علامت یکبار اجرای اصلاح داده
|
||||
model DataFix {
|
||||
key String @id
|
||||
doneAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -428,12 +428,26 @@ export class ActivitiesController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
return this.activitiesService.moveToStage(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto.stageId,
|
||||
dto.position,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
complete(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Query('force') force?: string,
|
||||
@Query('undo') undo?: string,
|
||||
) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user, {
|
||||
force: force === '1' || force === 'true',
|
||||
undo: undo === '1' || undo === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
@@ -51,6 +52,43 @@ type ActivityActor = Pick<
|
||||
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
|
||||
>;
|
||||
|
||||
function assigneeIdList(row: {
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
}): string[] {
|
||||
const ids = row.assignees?.map((item) => item.id) ?? [];
|
||||
if (ids.length) return [...new Set(ids)];
|
||||
return row.assigneeId ? [row.assigneeId] : [];
|
||||
}
|
||||
|
||||
function canForceClose(
|
||||
actor: { userId: string; role: string },
|
||||
createdById: string | null | undefined,
|
||||
): boolean {
|
||||
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
|
||||
}
|
||||
|
||||
function canMutateActivity(
|
||||
actor: { userId: string; role: string },
|
||||
row: {
|
||||
createdById?: string | null;
|
||||
editPolicy?: 'creator' | 'assignees' | null;
|
||||
assigneeId?: string | null;
|
||||
assignees?: Array<{ id: string }>;
|
||||
},
|
||||
): boolean {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
if (row.createdById && actor.userId === row.createdById) return true;
|
||||
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
|
||||
return assigneeIdList(row).includes(actor.userId);
|
||||
}
|
||||
|
||||
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
|
||||
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
|
||||
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
|
||||
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
|
||||
}
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
|
||||
@@ -81,6 +119,9 @@ type ActivityRow = {
|
||||
reminderNotifySms: boolean;
|
||||
reminderSentAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
completionMode?: 'any' | 'all';
|
||||
completedByIds?: string[];
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
importSource?: string;
|
||||
promotedFromPriorAt?: Date | null;
|
||||
tags: string[];
|
||||
@@ -185,22 +226,39 @@ export class ActivitiesService {
|
||||
return { stages, types };
|
||||
}
|
||||
|
||||
/** کارهای بازِ «من» که سهمشان ثبت شده، از میز کار شخص خارج میشوند. */
|
||||
private deskScope(
|
||||
actor: ActivityActor,
|
||||
desk?: 'mine' | 'team',
|
||||
): Prisma.ActivityWhereInput {
|
||||
if (desk !== 'mine') return activityAccessWhere(actor);
|
||||
return {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
},
|
||||
{
|
||||
NOT: {
|
||||
completionMode: 'all',
|
||||
status: 'open',
|
||||
completedByIds: { has: actor.userId },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async summary(
|
||||
organizationId: string,
|
||||
actor: ActivityActor,
|
||||
desk?: 'mine' | 'team',
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: activityAccessWhere(actor);
|
||||
const scope = this.deskScope(actor, desk);
|
||||
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
@@ -329,9 +387,7 @@ export class ActivitiesService {
|
||||
where,
|
||||
include,
|
||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||
orderBy: entity?.leadId
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
@@ -385,7 +441,7 @@ export class ActivitiesService {
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
take: cap,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
@@ -490,18 +546,69 @@ export class ActivitiesService {
|
||||
return { counts, total };
|
||||
}
|
||||
|
||||
private listOrder(
|
||||
entity?: { leadId?: string },
|
||||
stageScope?: StageScope,
|
||||
): Prisma.ActivityOrderByWithRelationInput[] {
|
||||
if (entity?.leadId) return [{ createdAt: 'desc' }];
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
|
||||
private async reorderStage(
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
activityId: string,
|
||||
index: number,
|
||||
) {
|
||||
const where = await this.stages.whereForStage(organizationId, stageId);
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
const placed = placeId(currentIds, activityId, index);
|
||||
if (sameIds(currentIds, placed)) return;
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placed,
|
||||
(id, boardPosition) =>
|
||||
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToStage(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
stageId: string,
|
||||
position?: number,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, completedAt: true },
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (
|
||||
stage.kind === 'done' &&
|
||||
existing?.completionMode === 'all' &&
|
||||
assigneeIdList(existing).length > 1 &&
|
||||
!canForceClose(actor, existing.createdById)
|
||||
) {
|
||||
return this.complete(organizationId, id, actor);
|
||||
}
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -516,9 +623,13 @@ export class ActivitiesService {
|
||||
: stage.kind === 'open'
|
||||
? null
|
||||
: undefined,
|
||||
...(stage.kind === 'open' && existing?.status !== 'open'
|
||||
? { completedByIds: [] }
|
||||
: {}),
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
@@ -640,6 +751,9 @@ export class ActivitiesService {
|
||||
assignees: assigneeIds.length
|
||||
? { connect: assigneeIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
|
||||
completedByIds: [],
|
||||
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
@@ -708,8 +822,23 @@ export class ActivitiesService {
|
||||
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
|
||||
select: {
|
||||
status: true,
|
||||
type: true,
|
||||
typeDefId: true,
|
||||
fields: true,
|
||||
participantIds: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
editPolicy: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing || !canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('boardId' in dto) {
|
||||
@@ -777,6 +906,7 @@ export class ActivitiesService {
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
|
||||
let assigneeConnect: Array<{ id: string }> | undefined;
|
||||
let nextAssigneeIds: string[] | undefined;
|
||||
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
|
||||
const current = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
@@ -804,6 +934,7 @@ export class ActivitiesService {
|
||||
);
|
||||
data.assigneeId = assigneeIds[0]!;
|
||||
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
|
||||
nextAssigneeIds = assigneeIds;
|
||||
} else {
|
||||
const assigneeId = emptyToNull(dto.assigneeId ?? null);
|
||||
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
|
||||
@@ -817,12 +948,44 @@ export class ActivitiesService {
|
||||
data.assigneeId = assigneeId;
|
||||
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
|
||||
else assigneeConnect = [];
|
||||
nextAssigneeIds = assigneeId ? [assigneeId] : [];
|
||||
}
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
data.completedAt =
|
||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
||||
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
|
||||
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
|
||||
if (modeChanged) data.completionMode = nextMode;
|
||||
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
|
||||
data.editPolicy = dto.editPolicy;
|
||||
}
|
||||
const prevAssigneeIds = assigneeIdList(existing ?? {});
|
||||
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
|
||||
const explicitDone = dto.status === 'done';
|
||||
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
|
||||
const explicitCancel = dto.status === 'canceled';
|
||||
if (explicitDone) {
|
||||
data.status = 'done';
|
||||
data.completedAt = new Date();
|
||||
} else if (explicitReopen) {
|
||||
data.status = 'open';
|
||||
data.completedAt = null;
|
||||
data.completedByIds = [];
|
||||
} else if (explicitCancel) {
|
||||
data.status = 'canceled';
|
||||
} else if (nextMode === 'any' && modeChanged) {
|
||||
data.completedByIds = [];
|
||||
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
|
||||
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
|
||||
data.completedByIds = reconciled.completedByIds;
|
||||
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
|
||||
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
|
||||
if (reconciled.status === 'done' && existing?.status !== 'done') {
|
||||
data.status = 'done';
|
||||
data.completedAt = new Date();
|
||||
} else if (reconciled.status === 'open' && existing?.status === 'done') {
|
||||
data.status = 'open';
|
||||
data.completedAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.prisma.activity.update({
|
||||
@@ -846,7 +1009,7 @@ export class ActivitiesService {
|
||||
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
|
||||
);
|
||||
}
|
||||
if (dto.status === 'done' && existing?.status !== 'done') {
|
||||
if (data.status === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
@@ -856,18 +1019,74 @@ export class ActivitiesService {
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
options: { force?: boolean; undo?: boolean } = {},
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { status: true },
|
||||
select: {
|
||||
status: true,
|
||||
completionMode: true,
|
||||
completedByIds: true,
|
||||
createdById: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
|
||||
if (existing.status === 'canceled') {
|
||||
throw new BadRequestException('فعالیت لغو شده است');
|
||||
}
|
||||
|
||||
const assignees = assigneeIdList(existing);
|
||||
const allowedForce = canForceClose(actor, existing.createdById);
|
||||
if (options.force && !allowedForce) {
|
||||
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
|
||||
}
|
||||
|
||||
if (options.undo) {
|
||||
if (existing.completionMode !== 'all' || existing.status !== 'open') {
|
||||
throw new BadRequestException('سهم انجامشده را فقط تا وقتی فعالیت باز است میتوان لغو کرد');
|
||||
}
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
|
||||
},
|
||||
include,
|
||||
});
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
const personal =
|
||||
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
|
||||
if (!personal) {
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
include,
|
||||
});
|
||||
if (existing.status !== 'done') await this.fireDone(organizationId, row);
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
}
|
||||
|
||||
if (!assignees.includes(actor.userId)) {
|
||||
throw new ForbiddenException('فقط مسئول این فعالیت میتواند سهم خودش را ثبت کند');
|
||||
}
|
||||
const reconciled = reconcileAllShares(assignees, [
|
||||
...existing.completedByIds,
|
||||
actor.userId,
|
||||
]);
|
||||
const row = await this.prisma.activity.update({
|
||||
where: { id },
|
||||
data: { status: 'done', completedAt: new Date() },
|
||||
data: {
|
||||
completedByIds: reconciled.completedByIds,
|
||||
status: reconciled.status,
|
||||
completedAt: reconciled.status === 'done' ? new Date() : null,
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (existing?.status !== 'done') {
|
||||
if (reconciled.status === 'done' && existing.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
return this.toDto(row, await this.ctx(organizationId));
|
||||
@@ -911,6 +1130,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const existing = await this.prisma.activity.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!existing || !canMutateActivity(actor, existing)) {
|
||||
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
|
||||
}
|
||||
const images = await this.prisma.activityImage.findMany({
|
||||
where: { activityId: id, organizationId },
|
||||
select: { storageKey: true, sizeBytes: true },
|
||||
@@ -931,6 +1162,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<ActivityImageDto> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!gate || !canMutateActivity(actor, gate)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
@@ -1025,6 +1268,18 @@ export class ActivitiesService {
|
||||
actor: ActivityActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, activityId, actor);
|
||||
const gate = await this.prisma.activity.findFirst({
|
||||
where: { id: activityId, organizationId },
|
||||
select: {
|
||||
createdById: true,
|
||||
editPolicy: true,
|
||||
assigneeId: true,
|
||||
assignees: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!gate || !canMutateActivity(actor, gate)) {
|
||||
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
|
||||
}
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
@@ -1073,16 +1328,7 @@ export class ActivitiesService {
|
||||
typeRows: TypeRow[] = [],
|
||||
): Prisma.ActivityWhereInput {
|
||||
const { start, end } = dayBounds();
|
||||
const scope: Prisma.ActivityWhereInput =
|
||||
desk === 'mine'
|
||||
? {
|
||||
OR: [
|
||||
{ assigneeId: actor.userId },
|
||||
{ assignees: { some: { id: actor.userId } } },
|
||||
{ participantIds: { has: actor.userId } },
|
||||
],
|
||||
}
|
||||
: activityAccessWhere(actor);
|
||||
const scope = this.deskScope(actor, desk);
|
||||
const facetAssignee = facets?.assigneeId?.trim();
|
||||
const facetTypeDef = facets?.typeDefId?.trim();
|
||||
const facetTitle = facets?.title?.trim();
|
||||
@@ -1383,6 +1629,9 @@ export class ActivitiesService {
|
||||
reminderNotifySms: row.reminderNotifySms,
|
||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
completionMode: row.completionMode ?? 'any',
|
||||
completedByIds: row.completedByIds ?? [],
|
||||
editPolicy: row.editPolicy ?? 'creator',
|
||||
importSource: row.importSource ?? 'manual',
|
||||
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
|
||||
tags: row.tags ?? [],
|
||||
|
||||
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
@@ -316,7 +322,7 @@ export class CreateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -378,6 +384,14 @@ export class CreateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@@ -416,7 +430,7 @@ export class UpdateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@@ -478,4 +492,12 @@ export class UpdateActivityDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'all'])
|
||||
completionMode?: 'any' | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import type {
|
||||
StorageUsageDto,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||
import type {} from 'multer';
|
||||
@@ -848,6 +852,9 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
@@ -879,9 +886,10 @@ export class AuthService {
|
||||
storage,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: []) as AuthUserDto['permissions'],
|
||||
permissions: permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
) as AuthUserDto['permissions'],
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
const peers = await this.prisma.teamMember.findMany({
|
||||
where: {
|
||||
teamId: { in: memberships.map((m) => m.teamId) },
|
||||
team: { organizationId },
|
||||
user: {
|
||||
memberships: { some: { organizationId, isActive: true } },
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
defaultEnabledModules,
|
||||
isPermanentBillingPlan,
|
||||
isOrgModuleKey,
|
||||
modulesForProductPlan,
|
||||
PRODUCT_PLAN_LIMITS,
|
||||
type ProductPlanCode,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||
@@ -414,7 +417,37 @@ export class BillingService {
|
||||
}
|
||||
|
||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||
return defaultEnabledModules();
|
||||
return modulesForProductPlan('core');
|
||||
}
|
||||
|
||||
/** سطح امکانات را روی همهٔ محیطهای مالک میگذارد و سقف هسته/تیم/سازمان را هم تنظیم میکند. */
|
||||
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
|
||||
const modules = modulesForProductPlan(code);
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, role: 'owner', isActive: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
|
||||
for (const id of orgIds) {
|
||||
await this.setOrgModules(id, modules);
|
||||
}
|
||||
const limits = PRODUCT_PLAN_LIMITS[code];
|
||||
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
|
||||
const account = await this.getByOwnerUserId(owner.userId);
|
||||
if (account) {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
|
||||
...(limits.maxOrganizations != null
|
||||
? { maxOrganizations: limits.maxOrganizations }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
async countExpiringSoon() {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
SectionDataAccess,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
|
||||
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
activitiesDeepWhere,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notesDeepWhere,
|
||||
projectsDeepWhere,
|
||||
routinesDeepWhere,
|
||||
sectionOwnerIdUnion,
|
||||
ticketsDeepWhere,
|
||||
} from './data-access';
|
||||
|
||||
@@ -60,6 +61,65 @@ export type Actor = {
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
type DeepAccessActor = ScopedActor &
|
||||
Partial<
|
||||
Pick<
|
||||
Actor,
|
||||
| 'customersAccess'
|
||||
| 'leadsAccess'
|
||||
| 'salesAccess'
|
||||
| 'activitiesAccess'
|
||||
| 'notesAccess'
|
||||
| 'routinesAccess'
|
||||
| 'projectsAccess'
|
||||
| 'ticketsAccess'
|
||||
| 'teamPeerIds'
|
||||
>
|
||||
>;
|
||||
|
||||
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
|
||||
switch (scope) {
|
||||
case 'contacts':
|
||||
return actor.customersAccess;
|
||||
case 'leads':
|
||||
return actor.leadsAccess;
|
||||
case 'deals':
|
||||
return actor.salesAccess;
|
||||
case 'activities':
|
||||
return actor.activitiesAccess;
|
||||
case 'notes':
|
||||
return actor.notesAccess;
|
||||
case 'routines':
|
||||
return actor.routinesAccess;
|
||||
case 'projects':
|
||||
return actor.projectsAccess;
|
||||
case 'tickets':
|
||||
return actor.ticketsAccess;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* شناسههای مالک از قانون عمق داده.
|
||||
* legacy = بخش عمیق روی این Actor نیست.
|
||||
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
|
||||
*/
|
||||
function deepOwnerIds(
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope,
|
||||
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
|
||||
const section = sectionForScope(actor, scope);
|
||||
if (!section) return { kind: 'legacy' };
|
||||
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
|
||||
const ids = sectionOwnerIdUnion(section, {
|
||||
userId: actor.userId,
|
||||
teamPeerIds: actor.teamPeerIds ?? null,
|
||||
});
|
||||
if (ids === null) return { kind: 'open' };
|
||||
return { kind: 'ids', ids };
|
||||
}
|
||||
|
||||
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
|
||||
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
|
||||
const s = actor.scopes?.[scope];
|
||||
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
|
||||
|
||||
/** فیلتر مالکیت؛ پیشفرض بخش «مشتریان» (Person / Company). برای فاکتور/پیشفاکتور scope=finance. */
|
||||
export function ownerWhere(
|
||||
actor: ScopedActor,
|
||||
actor: DeepAccessActor,
|
||||
scope: DataScope = 'contacts',
|
||||
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
|
||||
const deep = deepOwnerIds(actor, scope);
|
||||
if (deep.kind === 'open') return {};
|
||||
if (deep.kind === 'ids') {
|
||||
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
|
||||
if (!deep.ids.length) return { ownerId: '__no_access__' };
|
||||
return { ownerId: { in: deep.ids } };
|
||||
}
|
||||
const ids = scopedOwnerIds(actor, scope);
|
||||
if (!ids) return {};
|
||||
if (ids.length === 1) return { ownerId: ids[0]! };
|
||||
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
|
||||
}
|
||||
|
||||
/** فعالیتها بر اساس مسئول اصلی یا مسئولان مشترک */
|
||||
export function assigneeWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ActivityWhereInput {
|
||||
const ids = scopedOwnerIds(actor, 'activities');
|
||||
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
|
||||
const deep = deepOwnerIds(actor, 'activities');
|
||||
const ids =
|
||||
deep.kind === 'open'
|
||||
? null
|
||||
: deep.kind === 'ids'
|
||||
? deep.ids
|
||||
: scopedOwnerIds(actor, 'activities');
|
||||
if (!ids) return {};
|
||||
if (!ids.length) return { assigneeId: '__no_access__' };
|
||||
return {
|
||||
OR: [
|
||||
{ assigneeId: { in: ids } },
|
||||
|
||||
@@ -112,9 +112,13 @@ export class CompaniesService {
|
||||
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
|
||||
];
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersCompanyWhere(actor),
|
||||
},
|
||||
...and,
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
CustomerGroupsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||
export function canonicalIranMobile(value?: string | null): string | null {
|
||||
const n = normalizePhone(value);
|
||||
if (!n) return null;
|
||||
let d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||
const canon = canonicalIranMobile(value);
|
||||
if (!canon) return [];
|
||||
const core = canon.slice(1);
|
||||
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||
}
|
||||
|
||||
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||
const keys = iranMobileLookupKeys(value);
|
||||
if (keys.length) return keys;
|
||||
const n = normalizePhone(value);
|
||||
return n ? [n] : [];
|
||||
}
|
||||
|
||||
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||
export function storedPhoneNumber(value?: string | null): string | null {
|
||||
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from './normalize';
|
||||
import {
|
||||
asAddresses,
|
||||
asCustomFields,
|
||||
@@ -213,11 +218,15 @@ export class PeopleService implements OnModuleInit {
|
||||
if (disc) and.push(disc);
|
||||
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
...(moods.length ? { mood: { in: moods } } : {}),
|
||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...customersPersonWhere(actor),
|
||||
},
|
||||
...(moods.length ? [{ mood: { in: moods } }] : []),
|
||||
...(sources.length ? [{ importSource: { in: sources } }] : []),
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,7 +408,10 @@ export class PeopleService implements OnModuleInit {
|
||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||
) {
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (input.mobile) or.push({ mobile: input.mobile });
|
||||
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (input.email) or.push({ email: input.email });
|
||||
if (!or.length) return;
|
||||
await this.prisma.person.updateMany({
|
||||
@@ -441,8 +453,8 @@ export class PeopleService implements OnModuleInit {
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
phone: storedPhoneNumber(dto.phone),
|
||||
mobile: storedPhoneNumber(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
website: emptyToNull(dto.website),
|
||||
instagram: emptyToNull(dto.instagram),
|
||||
@@ -553,8 +565,8 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||
@@ -641,10 +653,13 @@ export class PeopleService implements OnModuleInit {
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||
|
||||
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||
|
||||
@Injectable()
|
||||
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
void this.run();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||
SELECT 1 AS ok
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND lower(table_name) = 'person'
|
||||
AND lower(column_name) = 'gender'
|
||||
`;
|
||||
if (!ready.length) {
|
||||
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (done.length) return;
|
||||
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||
try {
|
||||
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (again.length) return;
|
||||
|
||||
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||
merged += result.merged;
|
||||
rewritten += result.rewritten;
|
||||
failed += result.failed;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.error(
|
||||
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed) return;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||
ON CONFLICT ("key") DO NOTHING
|
||||
`;
|
||||
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||
} finally {
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||
import { MontifyConnectionsService } from './connections.service';
|
||||
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function mobileVariants(mobile: string): string[] {
|
||||
const digits = mobile.replace(/\D/g, '');
|
||||
let core = digits;
|
||||
if (core.startsWith('0098')) core = core.slice(4);
|
||||
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
|
||||
else if (core.startsWith('0')) core = core.slice(1);
|
||||
if (!core) return [mobile];
|
||||
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
|
||||
return phoneIdentityKeys(mobile);
|
||||
}
|
||||
|
||||
function formatAddress(a: MontifyAddress): string | null {
|
||||
@@ -417,7 +415,7 @@ export class MontifySyncService {
|
||||
}> {
|
||||
const externalId = String(c.id);
|
||||
const { firstName, lastName } = splitName(c);
|
||||
const mobile = normalizePhone(c.phone ?? null);
|
||||
const mobile = storedPhoneNumber(c.phone ?? null);
|
||||
const email = normalizeEmail(c.email ?? null);
|
||||
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
|
||||
const profile: CustomerProfile = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import {
|
||||
pickAddress,
|
||||
pickCity,
|
||||
pickNationalId,
|
||||
pickRasteh,
|
||||
tahesabProfilePatch,
|
||||
} from '../moshtari-profile';
|
||||
import {
|
||||
assertFactorCode,
|
||||
buildFactorCode,
|
||||
@@ -116,12 +123,52 @@ function testSanadBuilders() {
|
||||
assert(bank[12] === fc, 'bank factor index');
|
||||
}
|
||||
|
||||
function testMoshtariProfile() {
|
||||
const row = {
|
||||
CodeMelli: '00112312311',
|
||||
Address: 'خیابان ولیعصر',
|
||||
City: 'تهران',
|
||||
Rasteh: 2,
|
||||
BDate: null,
|
||||
};
|
||||
assert(pickNationalId(row) === '00112312311', 'national id');
|
||||
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
|
||||
assert(pickCity(row) === 'تهران', 'city');
|
||||
assert(pickRasteh(row) === 2, 'rasteh');
|
||||
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
|
||||
|
||||
const patch = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: { keep: true },
|
||||
});
|
||||
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
|
||||
const fields = patch.customFields as Record<string, unknown>;
|
||||
assert(fields.nationalId === '00112312311', 'cf national');
|
||||
assert(fields.city === 'تهران', 'cf city');
|
||||
assert(fields.rasteh === 2, 'cf rasteh');
|
||||
assert(fields.keep === true, 'cf merge');
|
||||
|
||||
const emptyKeep = tahesabProfilePatch(
|
||||
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
|
||||
{
|
||||
birthDate: new Date('2000-01-01'),
|
||||
addresses: ['قدیمی'],
|
||||
customFields: { nationalId: '111' },
|
||||
},
|
||||
);
|
||||
assert(emptyKeep.addresses == null, 'empty does not wipe address');
|
||||
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
|
||||
assert(emptyKeep.birthDate == null, 'existing birth kept');
|
||||
}
|
||||
|
||||
function main() {
|
||||
testCryptoRoundtrip();
|
||||
testFactorCode();
|
||||
testMobile();
|
||||
testMandehNormalize();
|
||||
testSanadBuilders();
|
||||
testMoshtariProfile();
|
||||
console.log('tahesab unit checks: OK');
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
@@ -195,6 +205,12 @@ export class SyncGroupsPageDto {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** اجرای دستی همگامسازی همهٔ مشتریان (وارد + بهروز کردن لینکشدهها) */
|
||||
export class SyncAllCustomersDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
}
|
||||
|
||||
export class ExportPeopleToTahesabDto {
|
||||
@IsString()
|
||||
connectionId!: string;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
|
||||
export function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function trimStr(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** کد ملی از پاسخ لیست مشتری تهحساب */
|
||||
export function pickNationalId(row: Record<string, unknown>): string | null {
|
||||
return (
|
||||
trimStr(row.CodeMelli) ??
|
||||
trimStr(row.CodeMeli) ??
|
||||
trimStr(row.codeMeli) ??
|
||||
trimStr(row.NationalId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function pickAddress(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
|
||||
}
|
||||
|
||||
export function pickCity(row: Record<string, unknown>): string | null {
|
||||
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
|
||||
}
|
||||
|
||||
/** رسته حساب در تهحساب (عدد یا رشته) */
|
||||
export function pickRasteh(row: Record<string, unknown>): string | number | null {
|
||||
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
const s = String(raw).trim();
|
||||
if (!s || s === '-1') return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) && String(n) === s ? n : s;
|
||||
}
|
||||
|
||||
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function asAddressList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((a) => (typeof a === 'string' ? a.trim() : ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلدهای پروفایل قابلدریافت از DoListMoshtari را روی شخص مینشاند.
|
||||
* مقدار خالی تهحساب، دادهٔ موجود CRM را پاک نمیکند.
|
||||
*/
|
||||
export function tahesabProfilePatch(
|
||||
row: Record<string, unknown>,
|
||||
existing: {
|
||||
birthDate: Date | null;
|
||||
addresses: unknown;
|
||||
customFields: unknown;
|
||||
},
|
||||
): Prisma.PersonUpdateInput {
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
const birthDate = pickBirthDate(row);
|
||||
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
|
||||
|
||||
const address = pickAddress(row);
|
||||
const city = pickCity(row);
|
||||
if (address) {
|
||||
const line = city ? `${address}، ${city}` : address;
|
||||
data.addresses = [line] as Prisma.InputJsonValue;
|
||||
} else if (city && asAddressList(existing.addresses).length === 0) {
|
||||
data.addresses = [city] as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
const fields = asCustomFieldRecord(existing.customFields);
|
||||
let fieldsChanged = false;
|
||||
const nationalId = pickNationalId(row);
|
||||
if (nationalId) {
|
||||
fields.nationalId = nationalId;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (city) {
|
||||
fields.city = city;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (address) {
|
||||
fields.address = address;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
const rasteh = pickRasteh(row);
|
||||
if (rasteh != null) {
|
||||
fields.rasteh = rasteh;
|
||||
fieldsChanged = true;
|
||||
}
|
||||
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
|
||||
return data;
|
||||
}
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
@@ -21,10 +26,25 @@ import {
|
||||
SearchTahesabDto,
|
||||
SyncGroupsPageDto,
|
||||
} from './dto';
|
||||
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { formatJalaliDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
function canonicalMobileUpdate(
|
||||
incoming: string | null,
|
||||
current: { mobile: string | null; phone: string | null },
|
||||
): { mobile?: string; phone?: string } {
|
||||
const mobile =
|
||||
canonicalIranMobile(incoming) ??
|
||||
canonicalIranMobile(current.mobile) ??
|
||||
canonicalIranMobile(current.phone);
|
||||
if (!mobile) return {};
|
||||
const phone = canonicalIranMobile(current.phone);
|
||||
return phone ? { mobile, phone } : { mobile };
|
||||
}
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
const LIST_PAGE_SIZE = 2000;
|
||||
const LIST_TIMEOUT_MS = 55_000;
|
||||
@@ -64,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
|
||||
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
|
||||
}
|
||||
|
||||
function pickBirthDate(row: Record<string, unknown>): Date | null {
|
||||
const raw =
|
||||
row.BDate ??
|
||||
row.bDate ??
|
||||
row.Tarikh_Tavalod ??
|
||||
row.tarikh_tavalod ??
|
||||
row.BirthDate;
|
||||
if (raw == null) return null;
|
||||
return parseBirthDate(String(raw));
|
||||
}
|
||||
|
||||
function firstAddress(raw: unknown): string | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const first = raw.find((a) => typeof a === 'string' && a.trim());
|
||||
@@ -419,6 +428,77 @@ export class TahesabPersonLinkService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* کدهایی که لینک ندارند ولی شمارهشان مال مشتریِ از قبل لینکشدهٔ همین اتصال است.
|
||||
* یک شخص فقط یک کد تهحساب دارد؛ این کدها مشتری تازه نیستند.
|
||||
*/
|
||||
private async duplicateOwners(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
rows: Array<{ moshtariCode: number | null; tel: string }>,
|
||||
) {
|
||||
const pending = rows.filter(
|
||||
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
|
||||
);
|
||||
const keys = [
|
||||
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
|
||||
];
|
||||
const out = new Map<
|
||||
number,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
if (!keys.length) return out;
|
||||
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
accountingLinks: { some: { connectionId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
accountingLinks: {
|
||||
where: { connectionId },
|
||||
select: { moshtariCode: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const byKey = new Map<
|
||||
string,
|
||||
{ moshtariCode: number; personId: string; personName: string }
|
||||
>();
|
||||
for (const person of people) {
|
||||
const link = person.accountingLinks[0];
|
||||
if (!link) continue;
|
||||
const info = {
|
||||
moshtariCode: link.moshtariCode,
|
||||
personId: person.id,
|
||||
personName: `${person.firstName} ${person.lastName}`.trim(),
|
||||
};
|
||||
for (const key of [
|
||||
...phoneIdentityKeys(person.mobile),
|
||||
...phoneIdentityKeys(person.phone),
|
||||
]) {
|
||||
if (!byKey.has(key)) byKey.set(key, info);
|
||||
}
|
||||
}
|
||||
for (const row of pending) {
|
||||
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
|
||||
const hit = byKey.get(key);
|
||||
if (hit && hit.moshtariCode !== row.moshtariCode) {
|
||||
out.set(row.moshtariCode, hit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async search(organizationId: string, dto: SearchTahesabDto) {
|
||||
const { api } = await this.connections.getApi(
|
||||
organizationId,
|
||||
@@ -497,7 +577,7 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
|
||||
|
||||
const results = mapped.map((row) => {
|
||||
const linked = mapped.map((row) => {
|
||||
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
|
||||
return {
|
||||
...row,
|
||||
@@ -508,6 +588,22 @@ export class TahesabPersonLinkService {
|
||||
: null,
|
||||
};
|
||||
});
|
||||
const duplicates = await this.duplicateOwners(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linked.filter((row) => !row.alreadyLinked),
|
||||
);
|
||||
const results = linked.map((row) => {
|
||||
if (row.alreadyLinked || row.moshtariCode == null) return row;
|
||||
const dup = duplicates.get(row.moshtariCode);
|
||||
if (!dup) return row;
|
||||
return {
|
||||
...row,
|
||||
duplicateOfCode: dup.moshtariCode,
|
||||
duplicatePersonId: dup.personId,
|
||||
duplicatePersonName: dup.personName,
|
||||
};
|
||||
});
|
||||
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
@@ -539,6 +635,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -577,7 +674,7 @@ export class TahesabPersonLinkService {
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = tel || null;
|
||||
const mobile = storedPhoneNumber(tel);
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
@@ -603,29 +700,49 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...(mobile
|
||||
? {
|
||||
mobile: existing.mobile || mobile,
|
||||
phone: existing.phone || mobile,
|
||||
}
|
||||
: {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
...tahesabProfilePatch(row, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته میشود؛
|
||||
// وگرنه upsert لینک، کد قبلی را جابهجا و آن مشتری را دوباره «واردنشده» میکند.
|
||||
const byMobile = mobile
|
||||
? await this.prisma.person.findFirst({
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمیخورند
|
||||
// تا کد قبلی جابهجا نشود و این کد هم مشتری تکراری نسازد.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
accountingLinks: { none: { connectionId: dto.connectionId } },
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
include: {
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
: [];
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId: byMobile.id, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
return {
|
||||
personId: byMobile.id,
|
||||
link: this.linkDto(existing),
|
||||
duplicateOf: linkedCode,
|
||||
};
|
||||
}
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -645,11 +762,17 @@ export class TahesabPersonLinkService {
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
...tahesabProfilePatch(row, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
} else {
|
||||
const profile = tahesabProfilePatch(row, {
|
||||
birthDate: null,
|
||||
addresses: [],
|
||||
customFields: {},
|
||||
});
|
||||
const created = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -662,7 +785,9 @@ export class TahesabPersonLinkService {
|
||||
notes: `واردشده از تهحساب (کد ${dto.moshtariCode})`,
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
birthDate: pickBirthDate(row),
|
||||
birthDate: (profile.birthDate as Date | undefined) ?? null,
|
||||
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
|
||||
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
|
||||
},
|
||||
});
|
||||
personId = created.id;
|
||||
@@ -693,18 +818,184 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** پروفایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
...tahesabProfilePatch(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 duplicate = 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 {
|
||||
const created = await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
if (created.duplicateOf) duplicate += 1;
|
||||
else 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} بهروز شد`,
|
||||
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
|
||||
`${failed} خطا`,
|
||||
].join('، ');
|
||||
const error = errors.length ? errors.join('؛ ') : null;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: summary,
|
||||
customerSyncLastError: error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
duplicate,
|
||||
failed,
|
||||
summary,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
@@ -799,19 +1090,16 @@ export class TahesabPersonLinkService {
|
||||
try {
|
||||
const linkedPersonId = linkedByCode.get(moshtariCode);
|
||||
if (linkedPersonId) {
|
||||
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
|
||||
if (birthDate) {
|
||||
await this.prisma.person.updateMany({
|
||||
where: { id: linkedPersonId, birthDate: null },
|
||||
data: { birthDate },
|
||||
});
|
||||
const row = rowsByCode.get(moshtariCode);
|
||||
if (row) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
moshtariCode,
|
||||
row,
|
||||
);
|
||||
}
|
||||
await this.applyTahesabGroup(
|
||||
organizationId,
|
||||
dto.connectionId,
|
||||
linkedPersonId,
|
||||
rowsByCode.get(moshtariCode),
|
||||
);
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
@@ -825,6 +1113,16 @@ export class TahesabPersonLinkService {
|
||||
{ connectionId: dto.connectionId, moshtariCode },
|
||||
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
|
||||
);
|
||||
if (r.duplicateOf) {
|
||||
items.push({
|
||||
moshtariCode,
|
||||
ok: true,
|
||||
skipped: true,
|
||||
personId: r.personId,
|
||||
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
items.push({ moshtariCode, ok: true, personId: r.personId });
|
||||
} catch (e) {
|
||||
const message =
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SearchTahesabDto,
|
||||
SearchTahesabProductsDto,
|
||||
SyncAsnadReportDto,
|
||||
SyncAllCustomersDto,
|
||||
SyncGroupsPageDto,
|
||||
UpdateTahesabConnectionDto,
|
||||
UpdateTahesabInvoiceSettingsDto,
|
||||
@@ -237,6 +238,16 @@ export class TahesabController {
|
||||
return this.links.syncGroupsPage(user.organizationId, dto);
|
||||
}
|
||||
|
||||
/** همهٔ مشتریان تهحساب: تازهها وارد، لینکشدهها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) بهروز */
|
||||
@Post('sync/customers-now')
|
||||
@Roles('owner', 'admin')
|
||||
syncCustomersNow(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: SyncAllCustomersDto,
|
||||
) {
|
||||
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
|
||||
}
|
||||
|
||||
@Get('sync/unlinked-people')
|
||||
@Roles('owner', 'admin')
|
||||
unlinkedPeople(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { AiService } from '../../ai/ai.service';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
@@ -17,6 +20,8 @@ export class CallSummaryService {
|
||||
private readonly stt: SttClientService,
|
||||
private readonly ai: AiService,
|
||||
private readonly notes: NotesService,
|
||||
private readonly connections: VoipConnectionsService,
|
||||
private readonly daftareshoma: DaftaresomaClient,
|
||||
) {}
|
||||
|
||||
@Cron('*/1 * * * *')
|
||||
@@ -74,7 +79,31 @@ export class CallSummaryService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
|
||||
if (daftId) {
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: { id: row.connectionId },
|
||||
});
|
||||
const token = conn
|
||||
? await this.connections.decryptApiToken(conn)
|
||||
: null;
|
||||
if (!token) {
|
||||
await this.markFailed(
|
||||
id,
|
||||
row.summaryAttempts,
|
||||
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const audio = await this.daftareshoma.downloadRecording(
|
||||
token,
|
||||
daftId,
|
||||
);
|
||||
transcript = await this.stt.transcribeAudio(audio);
|
||||
} else {
|
||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||
}
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id },
|
||||
data: { transcript },
|
||||
|
||||
@@ -11,8 +11,13 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { normalizePhone } from '../../contacts/normalize';
|
||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import {
|
||||
daftareshomaRecordingRef,
|
||||
parseDaftaresomaRecordingRef,
|
||||
} from './daftareshoma-webhook';
|
||||
|
||||
function digitsOnly(value: string | null | undefined): string {
|
||||
return (value ?? '').replace(/\D/g, '');
|
||||
@@ -35,6 +40,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
|
||||
variants.add(`0${d}`);
|
||||
variants.add(`98${d}`);
|
||||
}
|
||||
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
@@ -56,19 +62,32 @@ export class VoipCallsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly connections: VoipConnectionsService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly daftareshoma: DaftaresomaClient,
|
||||
) {}
|
||||
|
||||
async listRecent(
|
||||
organizationId: string,
|
||||
connectionId?: string,
|
||||
opts?: {
|
||||
connectionId?: string;
|
||||
direction?: string;
|
||||
status?: string;
|
||||
lineExtension?: string;
|
||||
},
|
||||
): Promise<VoipCallLogDto[]> {
|
||||
const rows = await this.prisma.voipCallLog.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
|
||||
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
|
||||
? { direction: opts.direction }
|
||||
: {}),
|
||||
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
|
||||
...(opts?.lineExtension?.trim()
|
||||
? { lineExtension: opts.lineExtension.trim() }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { syncedAt: 'desc' },
|
||||
take: 50,
|
||||
take: 100,
|
||||
});
|
||||
const personIds = rows
|
||||
.map((r) => r.personId)
|
||||
@@ -90,6 +109,7 @@ export class VoipCallsService {
|
||||
status: r.status,
|
||||
fromNumber: r.fromNumber,
|
||||
toNumber: r.toNumber,
|
||||
lineExtension: r.lineExtension ?? null,
|
||||
personId: r.personId,
|
||||
personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
|
||||
activityId: r.activityId,
|
||||
@@ -126,6 +146,9 @@ export class VoipCallsService {
|
||||
const toDigits = digitsOnly(event.to ?? event.callee);
|
||||
const fromNumber = fromNormalized || fromDigits || null;
|
||||
const toNumber = toNormalized || toDigits || null;
|
||||
const lineRaw = String(event.lineExtension ?? '').trim();
|
||||
const lineExtension =
|
||||
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
|
||||
const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
|
||||
const personId = await this.findPersonId(
|
||||
conn.organizationId,
|
||||
@@ -192,6 +215,7 @@ export class VoipCallsService {
|
||||
status,
|
||||
fromNumber,
|
||||
toNumber,
|
||||
lineExtension: lineExtension ?? existing.lineExtension,
|
||||
personId: personId ?? existing.personId,
|
||||
activityId: activityId ?? existing.activityId,
|
||||
durationSec,
|
||||
@@ -212,6 +236,7 @@ export class VoipCallsService {
|
||||
status,
|
||||
fromNumber,
|
||||
toNumber,
|
||||
lineExtension,
|
||||
personId,
|
||||
activityId,
|
||||
durationSec,
|
||||
@@ -239,6 +264,17 @@ export class VoipCallsService {
|
||||
await this.notifyIncoming(conn.organizationId, personId, fromNumber);
|
||||
}
|
||||
|
||||
if (
|
||||
conn.provider === 'daftareshoma' &&
|
||||
['completed', 'answered', 'missed'].includes(status)
|
||||
) {
|
||||
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
|
||||
this.logger.warn(
|
||||
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const link = await this.prisma.voipCallLog.findUnique({
|
||||
where: {
|
||||
connectionId_externalCallId: {
|
||||
@@ -260,10 +296,67 @@ export class VoipCallsService {
|
||||
userId: string,
|
||||
number: string,
|
||||
personId?: string,
|
||||
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> {
|
||||
): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
|
||||
const phone = normalizePhone(number) ?? digitsOnly(number);
|
||||
if (!phone) throw new BadRequestException('شماره معتبر نیست');
|
||||
|
||||
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
enabled: true,
|
||||
provider: 'daftareshoma',
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (daftareshomaConn) {
|
||||
const token = await this.connections.decryptApiToken(daftareshomaConn);
|
||||
const fromNumber = daftareshomaConn.fromNumber?.trim();
|
||||
const callerExtension = daftareshomaConn.callerExtension?.trim();
|
||||
if (!token || !fromNumber || !callerExtension) {
|
||||
throw new BadRequestException(
|
||||
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
|
||||
);
|
||||
}
|
||||
const toE164 = this.toPlusDigits(phone);
|
||||
const fromE164 = this.toPlusDigits(fromNumber);
|
||||
const extE164 = this.toPlusDigits(callerExtension);
|
||||
try {
|
||||
await this.daftareshoma.outgoingCall(token, {
|
||||
fromNumber: fromE164,
|
||||
toNumber: toE164,
|
||||
callerExtension: extE164,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'خطا';
|
||||
await this.prisma.voipConnection.update({
|
||||
where: { id: daftareshomaConn.id },
|
||||
data: { lastError: msg.slice(0, 400) },
|
||||
});
|
||||
throw new BadRequestException(msg);
|
||||
}
|
||||
|
||||
await this.prisma.voipCallLog.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: daftareshomaConn.id,
|
||||
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
fromNumber: fromE164,
|
||||
toNumber: toE164,
|
||||
personId: personId ?? null,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mode: 'daftareshoma',
|
||||
dialUrl: `daftareshoma:${toE164}`,
|
||||
};
|
||||
}
|
||||
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
@@ -317,6 +410,69 @@ export class VoipCallsService {
|
||||
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
|
||||
}
|
||||
|
||||
private toPlusDigits(raw: string): string {
|
||||
const d = digitsOnly(raw);
|
||||
if (!d) return raw.trim();
|
||||
if (raw.trim().startsWith('+')) return `+${d}`;
|
||||
if (d.startsWith('98')) return `+${d}`;
|
||||
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
|
||||
return `+${d}`;
|
||||
}
|
||||
|
||||
private async enrichDaftaresomaRecording(
|
||||
connectionId: string,
|
||||
callId: string,
|
||||
): Promise<void> {
|
||||
const conn = await this.prisma.voipConnection.findFirst({
|
||||
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
|
||||
});
|
||||
if (!conn) return;
|
||||
const log = await this.prisma.voipCallLog.findUnique({
|
||||
where: {
|
||||
connectionId_externalCallId: { connectionId, externalCallId: callId },
|
||||
},
|
||||
});
|
||||
if (!log) return;
|
||||
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
|
||||
if (
|
||||
parseDaftaresomaRecordingRef(log.recordingUrl) &&
|
||||
!['done', 'pending', 'processing'].includes(log.summaryStatus)
|
||||
) {
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
summaryStatus: 'pending',
|
||||
summaryError: null,
|
||||
summaryNextAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await this.connections.decryptApiToken(conn);
|
||||
if (!token) return;
|
||||
const row = await this.daftareshoma.findCallById(token, callId);
|
||||
const recordingId = row?.recordingId?.trim();
|
||||
if (!recordingId) return;
|
||||
|
||||
const ref = daftareshomaRecordingRef(recordingId);
|
||||
await this.prisma.voipCallLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
recordingUrl: ref,
|
||||
summaryStatus: ['done', 'pending', 'processing'].includes(
|
||||
log.summaryStatus,
|
||||
)
|
||||
? log.summaryStatus
|
||||
: 'pending',
|
||||
summaryError: null,
|
||||
summaryNextAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private shouldCreateActivity(status: string): boolean {
|
||||
return ['answered', 'completed', 'missed', 'failed'].includes(status);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ import type {
|
||||
CreateVoipConnectionRequest,
|
||||
UpdateVoipConnectionRequest,
|
||||
VoipConnectionDto,
|
||||
VoipProvider,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHmac } from 'crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
|
||||
function asProvider(value: string | null | undefined): VoipProvider {
|
||||
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VoipConnectionsService {
|
||||
constructor(
|
||||
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
|
||||
): Promise<VoipConnectionDto> {
|
||||
const label = dto.label?.trim();
|
||||
if (!label) throw new BadRequestException('نام اتصال الزامی است');
|
||||
const provider = asProvider(dto.provider);
|
||||
if (provider === 'daftareshoma') {
|
||||
const token = dto.apiToken?.trim();
|
||||
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
|
||||
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازماند؛ ثبت وبهوک بدون آنها ممکن است
|
||||
}
|
||||
|
||||
const secret = dto.webhookSecret?.trim();
|
||||
const secretEnc = secret ? this.crypto.encrypt(secret) : null;
|
||||
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
||||
const token = dto.apiToken?.trim();
|
||||
const tokenEnc = token ? this.crypto.encrypt(token) : null;
|
||||
const clickToCallUrl =
|
||||
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
|
||||
|
||||
const row = await this.prisma.voipConnection.create({
|
||||
data: {
|
||||
organizationId,
|
||||
label,
|
||||
provider,
|
||||
enabled: dto.enabled ?? true,
|
||||
clickToCallUrl,
|
||||
fromNumber: dto.fromNumber?.trim() || null,
|
||||
callerExtension: dto.callerExtension?.trim() || null,
|
||||
webhookSecretCiphertext: secretEnc?.ciphertext,
|
||||
webhookSecretIv: secretEnc?.iv,
|
||||
webhookSecretTag: secretEnc?.tag,
|
||||
apiTokenCiphertext: tokenEnc?.ciphertext,
|
||||
apiTokenIv: tokenEnc?.iv,
|
||||
apiTokenTag: tokenEnc?.tag,
|
||||
},
|
||||
});
|
||||
return this.toDto(row, 0);
|
||||
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
|
||||
if (dto.clickToCallUrl !== undefined) {
|
||||
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
|
||||
}
|
||||
if (dto.fromNumber !== undefined) {
|
||||
data.fromNumber = dto.fromNumber?.trim() || null;
|
||||
}
|
||||
if (dto.callerExtension !== undefined) {
|
||||
data.callerExtension = dto.callerExtension?.trim() || null;
|
||||
}
|
||||
if (dto.webhookSecret !== undefined) {
|
||||
const wh = dto.webhookSecret?.trim();
|
||||
if (!wh) {
|
||||
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
|
||||
data.webhookSecretTag = enc.tag;
|
||||
}
|
||||
}
|
||||
if (dto.apiToken !== undefined) {
|
||||
const tok = dto.apiToken?.trim();
|
||||
if (!tok) {
|
||||
data.apiTokenCiphertext = null;
|
||||
data.apiTokenIv = null;
|
||||
data.apiTokenTag = null;
|
||||
} else {
|
||||
const enc = this.crypto.encrypt(tok);
|
||||
data.apiTokenCiphertext = enc.ciphertext;
|
||||
data.apiTokenIv = enc.iv;
|
||||
data.apiTokenTag = enc.tag;
|
||||
}
|
||||
}
|
||||
const row = await this.prisma.voipConnection.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
|
||||
});
|
||||
}
|
||||
|
||||
async decryptApiToken(row: {
|
||||
apiTokenCiphertext: string | null;
|
||||
apiTokenIv: string | null;
|
||||
apiTokenTag: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
|
||||
return null;
|
||||
}
|
||||
return this.crypto.decrypt({
|
||||
ciphertext: row.apiTokenCiphertext,
|
||||
iv: row.apiTokenIv,
|
||||
tag: row.apiTokenTag,
|
||||
});
|
||||
}
|
||||
|
||||
verifySignature(
|
||||
rawBody: string | Buffer,
|
||||
signature: string | undefined,
|
||||
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
|
||||
row: {
|
||||
id: string;
|
||||
label: string;
|
||||
provider?: string | null;
|
||||
enabled: boolean;
|
||||
webhookSecretCiphertext: string | null;
|
||||
apiTokenCiphertext?: string | null;
|
||||
clickToCallUrl: string | null;
|
||||
fromNumber?: string | null;
|
||||
callerExtension?: string | null;
|
||||
lastEventAt: Date | null;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
provider: asProvider(row.provider),
|
||||
enabled: row.enabled,
|
||||
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
|
||||
apiTokenSet: Boolean(row.apiTokenCiphertext),
|
||||
clickToCallUrl: row.clickToCallUrl,
|
||||
fromNumber: row.fromNumber ?? null,
|
||||
callerExtension: row.callerExtension ?? null,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
lastError: row.lastError,
|
||||
callCount,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
|
||||
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export type DaftaresomaOutgoingCallInput = {
|
||||
fromNumber: string;
|
||||
toNumber: string;
|
||||
callerExtension: string;
|
||||
};
|
||||
|
||||
export type DaftaresomaCallSearchRow = {
|
||||
id?: string;
|
||||
fromNumber?: string;
|
||||
toNumber?: string;
|
||||
recordingId?: string | null;
|
||||
direction?: string;
|
||||
reportDispositionType?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
duration?: string;
|
||||
extension?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DaftaresomaClient {
|
||||
private readonly logger = new Logger(DaftaresomaClient.name);
|
||||
|
||||
async outgoingCall(
|
||||
apiToken: string,
|
||||
input: DaftaresomaOutgoingCallInput,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from_number: input.fromNumber,
|
||||
to_number: input.toNumber,
|
||||
caller_extension: input.callerExtension,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
this.logger.warn(
|
||||
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
|
||||
);
|
||||
throw new Error(
|
||||
text.trim()
|
||||
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
|
||||
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findCallById(
|
||||
apiToken: string,
|
||||
callId: string,
|
||||
): Promise<DaftaresomaCallSearchRow | null> {
|
||||
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
this.logger.warn(
|
||||
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
data?: DaftaresomaCallSearchRow[];
|
||||
};
|
||||
const row = Array.isArray(json.data) ? json.data[0] : null;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async downloadRecording(
|
||||
apiToken: string,
|
||||
recordingId: string,
|
||||
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
|
||||
const url = new URL(`${BASE_URL}/GetCallRecording`);
|
||||
url.searchParams.set('recordingId', recordingId);
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
Accept: '*/*',
|
||||
'User-Agent': 'MontactCRM-VoIP/1.0',
|
||||
},
|
||||
signal: AbortSignal.timeout(60000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
|
||||
);
|
||||
}
|
||||
const contentLength = Number(res.headers.get('content-length') || 0);
|
||||
if (contentLength > MAX_AUDIO_BYTES) {
|
||||
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
|
||||
}
|
||||
const buffer = await res.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_AUDIO_BYTES) {
|
||||
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
|
||||
}
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error('فایل ضبط خالی است');
|
||||
}
|
||||
const contentType =
|
||||
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
|
||||
const filename = `daftareshoma-${recordingId}.wav`;
|
||||
return { buffer, contentType, filename };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { VoipWebhookEvent } from '@mento/shared';
|
||||
|
||||
/** پیشوند ذخیرهشده در recordingUrl برای واکشی بعدی با توکن API */
|
||||
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
|
||||
|
||||
export function daftareshomaRecordingRef(recordingId: string): string {
|
||||
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
|
||||
}
|
||||
|
||||
export function parseDaftaresomaRecordingRef(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
|
||||
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
type Loose = Record<string, unknown>;
|
||||
|
||||
function str(v: unknown): string {
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
|
||||
function pick(obj: Loose, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = str(obj[k]);
|
||||
if (v) return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseDurationSec(raw: unknown): number | undefined {
|
||||
if (raw == null || raw === '') return undefined;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return Math.max(0, Math.round(raw));
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
if (!s) return undefined;
|
||||
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
|
||||
const parts = s.split(':').map((p) => Number(p));
|
||||
if (parts.some((n) => !Number.isFinite(n))) return undefined;
|
||||
if (parts.length === 3) {
|
||||
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* رویداد وبهوک دفتر شما را به VoipWebhookEvent تبدیل میکند.
|
||||
* اگر payload از قبل فرمت Montact باشد، همان را برمیگرداند.
|
||||
*/
|
||||
export function mapDaftaresomaWebhook(
|
||||
body: unknown,
|
||||
): VoipWebhookEvent | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const obj = body as Loose;
|
||||
|
||||
const legacyCallId = pick(obj, 'callId');
|
||||
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
|
||||
return body as VoipWebhookEvent;
|
||||
}
|
||||
|
||||
const eventType = pick(obj, 'EventType', 'eventType', 'event');
|
||||
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
|
||||
if (!callId) return null;
|
||||
|
||||
const caller = pick(
|
||||
obj,
|
||||
'CallerNumber',
|
||||
'callerNumber',
|
||||
'from',
|
||||
'From',
|
||||
'fromNumber',
|
||||
);
|
||||
/** خط سازمانی که تماس روی آن آمده/رفته */
|
||||
const lineExtension =
|
||||
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
|
||||
undefined;
|
||||
/** مقصد انتقال / شماره طرف مقابل روی خط */
|
||||
const transferred = pick(
|
||||
obj,
|
||||
'TransferredToNumber',
|
||||
'transferredToNumber',
|
||||
);
|
||||
|
||||
const startedAt =
|
||||
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
|
||||
const endedAt =
|
||||
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
|
||||
undefined;
|
||||
const durationSec = parseDurationSec(
|
||||
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
|
||||
);
|
||||
|
||||
const lower = eventType.toLowerCase();
|
||||
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
|
||||
const isStarted =
|
||||
lower.includes('started') ||
|
||||
lower.includes('ringing') ||
|
||||
lower.includes('transferstarted');
|
||||
const isEnded =
|
||||
lower.includes('ended') ||
|
||||
lower.includes('completed') ||
|
||||
lower.includes('hangup');
|
||||
|
||||
let status: string = 'completed';
|
||||
if (isStarted && !isEnded) {
|
||||
status = 'ringing';
|
||||
} else if (isEnded) {
|
||||
const result = obj.Result ?? obj.result;
|
||||
if (result === false || result === 'false' || result === 0) {
|
||||
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
|
||||
} else if (result === true || result === 'true' || result === 1) {
|
||||
status = 'completed';
|
||||
} else {
|
||||
status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
const recordingId = pick(
|
||||
obj,
|
||||
'RecordingId',
|
||||
'recordingId',
|
||||
'RecordId',
|
||||
'recordId',
|
||||
);
|
||||
|
||||
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
|
||||
// outbound: from=همکار، to=مشتری (TransferredToNumber)
|
||||
let from = caller || undefined;
|
||||
let to: string | undefined;
|
||||
if (isOutbound) {
|
||||
to = transferred || undefined;
|
||||
} else {
|
||||
to = transferred || lineExtension || undefined;
|
||||
}
|
||||
|
||||
const event: VoipWebhookEvent = {
|
||||
callId,
|
||||
direction: isOutbound ? 'outbound' : 'inbound',
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
lineExtension,
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
caller: from,
|
||||
callee: to,
|
||||
};
|
||||
if (recordingId) {
|
||||
event.recordingUrl = daftareshomaRecordingRef(recordingId);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
const obj = body as Loose;
|
||||
return Boolean(
|
||||
pick(obj, 'EventType', 'eventType') ||
|
||||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
|
||||
@MaxLength(80)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['generic', 'daftareshoma'])
|
||||
provider?: 'generic' | 'daftareshoma';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
|
||||
@MaxLength(500)
|
||||
clickToCallUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
apiToken?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
fromNumber?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
callerExtension?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
|
||||
@MaxLength(500)
|
||||
clickToCallUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
apiToken?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
fromNumber?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
callerExtension?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -18,17 +18,31 @@ export class SttClientService {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||
return this.transcribeAudio(audio, timeoutMs);
|
||||
}
|
||||
|
||||
async transcribeAudio(
|
||||
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
|
||||
timeoutMs?: number,
|
||||
): Promise<string> {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||
}
|
||||
|
||||
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||
}
|
||||
|
||||
const timeoutMs = Number(
|
||||
const waitMs = timeoutMs ?? Number(
|
||||
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||
);
|
||||
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
||||
|
||||
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||
const form = new FormData();
|
||||
const blob = new Blob([new Uint8Array(audio.buffer)], {
|
||||
type: audio.contentType,
|
||||
@@ -39,7 +53,7 @@ export class SttClientService {
|
||||
if (token) headers['X-STT-Token'] = token;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const timer = setTimeout(() => controller.abort(), waitMs);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
UpdateVoipConnectionDto,
|
||||
VoipClickToCallDto,
|
||||
} from './dto';
|
||||
import {
|
||||
isDaftaresomaWebhookPayload,
|
||||
mapDaftaresomaWebhook,
|
||||
} from './daftareshoma-webhook';
|
||||
|
||||
@Controller('integrations/voip')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@@ -78,8 +82,16 @@ export class VoipController {
|
||||
recent(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query('connectionId') connectionId?: string,
|
||||
@Query('direction') direction?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('lineExtension') lineExtension?: string,
|
||||
) {
|
||||
return this.calls.listRecent(user.organizationId, connectionId);
|
||||
return this.calls.listRecent(user.organizationId, {
|
||||
connectionId,
|
||||
direction,
|
||||
status,
|
||||
lineExtension,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('click-to-call')
|
||||
@@ -129,11 +141,21 @@ export class VoipWebhookController {
|
||||
if (!ok) throw new UnauthorizedException('امضای وبهوک نامعتبر است');
|
||||
}
|
||||
|
||||
if (!body?.callId) {
|
||||
let event: VoipWebhookEvent | null = null;
|
||||
if (
|
||||
row.provider === 'daftareshoma' ||
|
||||
isDaftaresomaWebhookPayload(body)
|
||||
) {
|
||||
event = mapDaftaresomaWebhook(body);
|
||||
} else if (body?.callId) {
|
||||
event = body;
|
||||
}
|
||||
|
||||
if (!event?.callId) {
|
||||
return { ok: false, error: 'callId required' };
|
||||
}
|
||||
|
||||
const result = await this.calls.ingestWebhook(connectionId, body);
|
||||
const result = await this.calls.ingestWebhook(connectionId, event);
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||
import { CallSummaryService } from './call-summary.service';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
import { VoipCallsService } from './calls.service';
|
||||
import { DaftaresomaClient } from './daftareshoma-client';
|
||||
import { SttClientService } from './stt-client.service';
|
||||
import { VoipController, VoipWebhookController } from './voip.controller';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
|
||||
TahesabCryptoService,
|
||||
VoipConnectionsService,
|
||||
VoipCallsService,
|
||||
DaftaresomaClient,
|
||||
SttClientService,
|
||||
CallSummaryService,
|
||||
],
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
@@ -374,7 +378,8 @@ export class WooSyncService {
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const mobile = storedPhoneNumber(billing.phone ?? null);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
@@ -387,7 +392,9 @@ export class WooSyncService {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
...(mobileKeys.length
|
||||
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../contacts/normalize';
|
||||
import { LeadStagesService } from '../leads/lead-stages.service';
|
||||
|
||||
@@ -458,7 +461,10 @@ export class LeadFormsService {
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: form.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
OR: [
|
||||
{ mobile: { in: phoneIdentityKeys(mobile) } },
|
||||
{ phone: { in: phoneIdentityKeys(mobile) } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
@@ -641,13 +647,16 @@ export class LeadFormsService {
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
const mobile = storedPhoneNumber(mapped.mobile);
|
||||
const phone = storedPhoneNumber(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
|
||||
}
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function normalizeDuplicateMode(raw?: string | null): string {
|
||||
|
||||
@@ -246,7 +246,11 @@ export class LeadsController {
|
||||
@Query('source') source?: string,
|
||||
@Query('unassigned') unassigned?: string,
|
||||
@Query('archived') archived?: string,
|
||||
@Query('converted') converted?: string,
|
||||
@Query('hasMobile') hasMobile?: string,
|
||||
@Query('followUp') followUp?: string,
|
||||
@Query('createdFrom') createdFrom?: string,
|
||||
@Query('createdTo') createdTo?: string,
|
||||
) {
|
||||
return this.leads.listIds(user, {
|
||||
q,
|
||||
@@ -258,7 +262,11 @@ export class LeadsController {
|
||||
source,
|
||||
unassigned: unassigned === '1' || unassigned === 'true',
|
||||
archived: archived === '1' || archived === 'true',
|
||||
converted: converted === '1' || converted === 'true',
|
||||
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
||||
followUp,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,8 +284,12 @@ export class LeadsController {
|
||||
@Query('source') source?: string,
|
||||
@Query('unassigned') unassigned?: string,
|
||||
@Query('archived') archived?: string,
|
||||
@Query('converted') converted?: string,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('hasMobile') hasMobile?: string,
|
||||
@Query('followUp') followUp?: string,
|
||||
@Query('createdFrom') createdFrom?: string,
|
||||
@Query('createdTo') createdTo?: string,
|
||||
) {
|
||||
return this.leads.list(user, {
|
||||
q,
|
||||
@@ -291,8 +303,12 @@ export class LeadsController {
|
||||
source,
|
||||
unassigned: unassigned === '1' || unassigned === 'true',
|
||||
archived: archived === '1' || archived === 'true',
|
||||
converted: converted === '1' || converted === 'true',
|
||||
sort,
|
||||
hasMobile: hasMobile === '1' || hasMobile === 'true',
|
||||
followUp,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,17 +32,29 @@ import {
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import {
|
||||
asAddresses,
|
||||
collectAddresses,
|
||||
toJsonAddresses,
|
||||
} from '../common/custom-fields';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
} from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import {
|
||||
addDays,
|
||||
isDayKey,
|
||||
tehranDayKey,
|
||||
tehranDayStart,
|
||||
} from '../routines/routine-dates';
|
||||
|
||||
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const LOGO_EXT: Record<string, string> = {
|
||||
@@ -54,13 +66,20 @@ const LOGO_EXT: Record<string, string> = {
|
||||
};
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
|
||||
const keys = phoneIdentityKeys(mobile);
|
||||
return {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
};
|
||||
}
|
||||
|
||||
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
||||
@@ -129,7 +148,11 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
): Prisma.LeadWhereInput {
|
||||
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
|
||||
@@ -148,22 +171,77 @@ export class LeadsService {
|
||||
if (opts.hasMobile) {
|
||||
and.push({ mobile: { not: '' } });
|
||||
}
|
||||
const follow = this.followUpWhere(opts.followUp);
|
||||
if (follow) and.push(follow);
|
||||
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
|
||||
if (createdRange) and.push(createdRange);
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(and.length ? { AND: and } : {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: actor.organizationId,
|
||||
...leadAccessWhere(actor),
|
||||
archivedAt: opts.archived ? { not: null } : null,
|
||||
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||
...(opts.region
|
||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||
...(opts.source
|
||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
||||
: {}),
|
||||
},
|
||||
...(opts.unassigned ? [{ assigneeId: null }] : []),
|
||||
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
|
||||
...and,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
|
||||
private tehranDayBounds(day = tehranDayKey()) {
|
||||
const start = tehranDayStart(day);
|
||||
const end = tehranDayStart(addDays(day, 1));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null {
|
||||
if (!followUp) return null;
|
||||
const { start, end } = this.tehranDayBounds();
|
||||
switch (followUp) {
|
||||
case 'due_today':
|
||||
return { nextFollowUpAt: { gte: start, lt: end } };
|
||||
case 'overdue':
|
||||
return { nextFollowUpAt: { not: null, lt: start } };
|
||||
case 'called_today':
|
||||
return { lastContactAt: { gte: start, lt: end } };
|
||||
case 'missed_today':
|
||||
return {
|
||||
nextFollowUpAt: { not: null, lt: end },
|
||||
OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }],
|
||||
};
|
||||
case 'no_follow_up':
|
||||
return { nextFollowUpAt: null };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private createdAtWhere(
|
||||
createdFrom?: string,
|
||||
createdTo?: string,
|
||||
): Prisma.LeadWhereInput | null {
|
||||
const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null;
|
||||
const to = isDayKey(createdTo)
|
||||
? tehranDayStart(addDays(createdTo, 1))
|
||||
: null;
|
||||
if (!from && !to) return null;
|
||||
return {
|
||||
createdAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lt: to } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,8 +259,12 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
sort?: string;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
): Promise<PaginatedResponse<LeadDto>> {
|
||||
await this.stages.ensureDefaults(actor.organizationId);
|
||||
@@ -230,7 +312,11 @@ export class LeadsService {
|
||||
source?: string;
|
||||
unassigned?: boolean;
|
||||
archived?: boolean;
|
||||
converted?: boolean;
|
||||
hasMobile?: boolean;
|
||||
followUp?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
},
|
||||
max = FILTERED_IDS_MAX,
|
||||
): Promise<FilteredIdsResponse> {
|
||||
@@ -322,10 +408,7 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, mobile),
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (person) {
|
||||
@@ -613,24 +696,43 @@ export class LeadsService {
|
||||
});
|
||||
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
||||
|
||||
let placedIds: string[] | null = null;
|
||||
let nextPosition = position ?? 0;
|
||||
if (position != null) {
|
||||
const rows = await this.prisma.lead.findMany({
|
||||
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, id, position);
|
||||
nextPosition = Math.max(0, placedIds.indexOf(id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId,
|
||||
position: position ?? 0,
|
||||
...(stage.isConverted
|
||||
? {}
|
||||
: {}),
|
||||
position: nextPosition,
|
||||
},
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(leadId, leadPosition) =>
|
||||
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
|
||||
);
|
||||
}
|
||||
if (before.stageId !== stageId) {
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
@@ -946,10 +1048,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, lead.mobile),
|
||||
});
|
||||
let createdPerson = false;
|
||||
|
||||
@@ -976,7 +1075,7 @@ export class LeadsService {
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: lead.mobile,
|
||||
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
birthDate: lead.birthDate,
|
||||
@@ -1090,7 +1189,7 @@ export class LeadsService {
|
||||
newStage
|
||||
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
|
||||
: Promise.resolve(0),
|
||||
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
|
||||
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
|
||||
convertedIds.length
|
||||
? this.prisma.lead.count({
|
||||
where: { ...base, stageId: { in: convertedIds } },
|
||||
@@ -1150,6 +1249,95 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
|
||||
async createFromExternal(input: {
|
||||
organizationId: string;
|
||||
siteName: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
mobile: string;
|
||||
storeName?: string | null;
|
||||
source?: string | null;
|
||||
}): Promise<{
|
||||
ok: true;
|
||||
created: boolean;
|
||||
duplicate: boolean;
|
||||
leadId: string | null;
|
||||
}> {
|
||||
const mobile = mobileKey(input.mobile);
|
||||
if (!mobile || !/^09\d{9}$/.test(mobile)) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const contactName =
|
||||
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
|
||||
const storeName = input.storeName?.trim() ?? '';
|
||||
const source = emptyToNull(input.source);
|
||||
const title = storeName || contactName || mobile;
|
||||
const siteName = input.siteName.trim() || 'سایت بیرونی';
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: { organizationId: input.organizationId, mobile, archivedAt: null },
|
||||
select: { id: true, source: true, contactName: true },
|
||||
});
|
||||
if (existing) {
|
||||
const patch: Prisma.LeadUpdateInput = {};
|
||||
if (!existing.source && source) patch.source = source;
|
||||
if (!existing.contactName && contactName) patch.contactName = contactName;
|
||||
if (Object.keys(patch).length) {
|
||||
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
|
||||
}
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: existing.id,
|
||||
type: 'note',
|
||||
title: 'درخواست دوباره از سایت',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: false, duplicate: true, leadId: existing.id };
|
||||
}
|
||||
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: personWithMobile(input.organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (person) {
|
||||
return { ok: true, created: false, duplicate: true, leadId: null };
|
||||
}
|
||||
|
||||
await this.stages.ensureDefaults(input.organizationId);
|
||||
const stages = await this.stages.list(input.organizationId);
|
||||
const stageId = stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const lead = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
title,
|
||||
contactName,
|
||||
mobile,
|
||||
source,
|
||||
notes: `ثبت از سایت: ${siteName}`,
|
||||
stageId,
|
||||
},
|
||||
});
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: lead.id,
|
||||
type: 'created',
|
||||
title: 'لید از سایت ثبت شد',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: true, duplicate: false, leadId: lead.id };
|
||||
}
|
||||
|
||||
async createFromImportRow(
|
||||
organizationId: string,
|
||||
createdById: string | null,
|
||||
@@ -1192,10 +1380,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
const existingPerson = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingPerson) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -70,6 +71,16 @@ export class MembershipsService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
|
||||
async assertSoleOwner(organizationId: string, userId: string) {
|
||||
const others = await this.countActiveOwners(organizationId, userId);
|
||||
if (others > 0) {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createMembership(data: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +89,9 @@ export class MembershipsService {
|
||||
commissionPercent?: number;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
if (data.role === 'owner' && data.isActive !== false) {
|
||||
await this.assertSoleOwner(data.organizationId, data.userId);
|
||||
}
|
||||
return this.prisma.membership.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateOrgRoleDto {
|
||||
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class RolePermissionPatchItemDto {
|
||||
|
||||
@@ -32,6 +32,8 @@ type RoleRow = {
|
||||
color: string;
|
||||
permissions: string[];
|
||||
dataAccessPolicy?: unknown;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
systemKey: string | null;
|
||||
parentId: string | null;
|
||||
createdAt: Date;
|
||||
@@ -79,6 +81,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -108,6 +112,8 @@ export class OrgRolesService {
|
||||
description: (dto.description ?? '').trim(),
|
||||
color: dto.color ?? parent?.color ?? 'teal',
|
||||
permissions,
|
||||
treasuryAccountIds: dto.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
|
||||
parentId,
|
||||
...(dto.dataAccessPolicy
|
||||
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
||||
@@ -135,6 +141,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -159,6 +167,12 @@ export class OrgRolesService {
|
||||
if (dto.dataAccessPolicy !== undefined) {
|
||||
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
|
||||
}
|
||||
if (dto.treasuryAccountIds !== undefined) {
|
||||
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
|
||||
}
|
||||
if (dto.treasuryOwnAccounts !== undefined) {
|
||||
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
|
||||
}
|
||||
|
||||
if (dto.parentId !== undefined) {
|
||||
const nextParentId =
|
||||
@@ -426,6 +440,8 @@ export class OrgRolesService {
|
||||
color: row.color,
|
||||
permissions: sanitizePermissions(row.permissions),
|
||||
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
||||
treasuryAccountIds: row.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
|
||||
systemKey,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { dealPriorWhere } from '../common/prior-archive';
|
||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -708,9 +708,9 @@ export class DealsExcelService {
|
||||
): Promise<string | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const code = cleanCode(row.personCode);
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
|
||||
const validMobile = mobileCore.length >= 8 ? mobile : null;
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
|
||||
const first = (row.personFirstName ?? '').trim();
|
||||
const last = (row.personName ?? '').trim();
|
||||
const fullName = [first, last].filter(Boolean).join(' ');
|
||||
@@ -731,7 +731,10 @@ export class DealsExcelService {
|
||||
}
|
||||
if (!found && validMobile) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: { organizationId: orgId, mobile: { contains: mobileCore } },
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -379,7 +380,8 @@ export class DealsService {
|
||||
}
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const position =
|
||||
let placedIds: string[] | null = null;
|
||||
let position =
|
||||
dto.position ??
|
||||
((
|
||||
await this.prisma.deal.aggregate({
|
||||
@@ -387,6 +389,17 @@ export class DealsService {
|
||||
_max: { position: true },
|
||||
})
|
||||
)._max.position ?? -1) + 1;
|
||||
if (dto.position != null) {
|
||||
const rows = await this.prisma.deal.findMany({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, deal.id, dto.position);
|
||||
position = Math.max(0, placedIds.indexOf(deal.id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
@@ -402,6 +415,15 @@ export class DealsService {
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(dealId, nextPosition) =>
|
||||
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
|
||||
@@ -31,8 +31,11 @@ export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.pipelinesService.list(user.organizationId);
|
||||
list(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('forLens') forLens?: string,
|
||||
) {
|
||||
return this.pipelinesService.list(user, forLens === '1');
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
|
||||
@@ -102,24 +102,55 @@ export class PipelinesService {
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<PipelineDto[]> {
|
||||
async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
|
||||
const organizationId =
|
||||
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
|
||||
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const rows = await this.prisma.pipeline.findMany({
|
||||
where: { organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
const counts = await this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: { organizationId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const lens = actor?.priorLens;
|
||||
const lensActive = forLens && (lens === 'current' || lens === 'archive');
|
||||
const access = actor ? dealAccessWhere(actor) : {};
|
||||
const prior = lensActive
|
||||
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
|
||||
: {};
|
||||
const [counts, lensCounts] = await Promise.all([
|
||||
this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
lensActive
|
||||
? this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: { organizationId, AND: [access, prior] },
|
||||
_count: { _all: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const dealCountByPipeline = new Map(
|
||||
counts.map((row) => [row.pipelineId, row._count._all]),
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
const lensCountByPipeline = new Map(
|
||||
lensCounts.map((row) => [row.pipelineId, row._count._all]),
|
||||
);
|
||||
const visible = lensActive
|
||||
? rows.filter((row) => {
|
||||
const inLens = lensCountByPipeline.get(row.id) ?? 0;
|
||||
const total = dealCountByPipeline.get(row.id) ?? 0;
|
||||
if (lens === 'archive') return inLens > 0;
|
||||
return inLens > 0 || total === 0;
|
||||
})
|
||||
: rows;
|
||||
return visible.map((row) => ({
|
||||
...this.toPipelineDto(row),
|
||||
dealCount: dealCountByPipeline.get(row.id) ?? 0,
|
||||
dealCount: lensActive
|
||||
? (lensCountByPipeline.get(row.id) ?? 0)
|
||||
: (dealCountByPipeline.get(row.id) ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { USER_ROLES, type UserRole } from '@mento/shared';
|
||||
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
|
||||
|
||||
export class PlatformLoginDto {
|
||||
@IsString()
|
||||
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
|
||||
quotaGb!: number | null;
|
||||
}
|
||||
|
||||
export class ApplyProductPlanDto {
|
||||
@IsIn(PRODUCT_PLAN_CODES)
|
||||
planCode!: ProductPlanCode;
|
||||
}
|
||||
|
||||
export class UpdateOrgModulesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
ApplyBillingPlanDto,
|
||||
ApplyProductPlanDto,
|
||||
SetStorageQuotaDto,
|
||||
SetUserCapDto,
|
||||
CreatePlatformAdminDto,
|
||||
@@ -168,6 +169,11 @@ export class PlatformController {
|
||||
return this.platform.setUserCap(id, dto.maxUsers);
|
||||
}
|
||||
|
||||
@Post('organizations/:id/product-plan')
|
||||
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
|
||||
return this.platform.applyProductPlan(id, dto.planCode);
|
||||
}
|
||||
|
||||
@Patch('organizations/:id/modules')
|
||||
updateOrganizationModules(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PlatformOrganizationDto,
|
||||
PlatformStatsDto,
|
||||
PlatformUserDto,
|
||||
ProductPlanCode,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { isBusinessField } from '@mento/shared';
|
||||
@@ -472,6 +473,16 @@ export class PlatformService {
|
||||
return this.getUser(userId);
|
||||
}
|
||||
|
||||
async applyProductPlan(id: string, planCode: ProductPlanCode) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||
await this.billing.applyProductPlan(id, planCode);
|
||||
return this.getOrganization(id);
|
||||
}
|
||||
|
||||
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
@@ -532,6 +543,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
if (isActive && !row.isActive) {
|
||||
if (row.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
|
||||
}
|
||||
if (!isActive && row.role === 'owner') {
|
||||
@@ -562,6 +576,9 @@ export class PlatformService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
|
||||
if (role === 'owner' && row.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
|
||||
}
|
||||
if (row.role === 'owner' && role !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
@@ -569,7 +586,7 @@ export class PlatformService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import type { PortalActor } from './portal-auth';
|
||||
|
||||
@@ -62,7 +63,7 @@ export class PortalService {
|
||||
mobileRaw: string,
|
||||
): Promise<PortalRequestOtpResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
@@ -123,7 +124,8 @@ export class PortalService {
|
||||
codeRaw: string,
|
||||
): Promise<PortalLoginResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const code = codeRaw.trim();
|
||||
if (!mobile || !code) {
|
||||
throw new BadRequestException('موبایل یا کد ناقص است');
|
||||
@@ -148,10 +150,15 @@ export class PortalService {
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: { organizationId: org.id, mobile },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
let person = mobileKeys.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
if (!person) {
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { projectCardAccessWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: string;
|
||||
role: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyName?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: auth.organizationId,
|
||||
role: auth.role,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
apiKeyName: auth.name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ApiKeysService {
|
||||
|
||||
async authenticate(
|
||||
rawKey: string,
|
||||
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
|
||||
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
|
||||
const key = rawKey.trim();
|
||||
if (!key.startsWith('mk_') || key.length < 20) {
|
||||
throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
@@ -71,7 +71,7 @@ export class ApiKeysService {
|
||||
const keyHash = this.hashKey(key);
|
||||
const row = await this.prisma.organizationApiKey.findFirst({
|
||||
where: { keyHash, revokedAt: null },
|
||||
select: { id: true, organizationId: true },
|
||||
select: { id: true, organizationId: true, name: true },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
|
||||
@@ -83,6 +83,7 @@ export class ApiKeysService {
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
apiKeyId: row.id,
|
||||
name: row.name,
|
||||
role: 'owner',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
|
||||
class CreateExternalLeadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
storeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const LEAD_RATE_WINDOW_MS = 60_000;
|
||||
const LEAD_RATE_MAX = 60;
|
||||
const leadHits = new Map<string, number[]>();
|
||||
|
||||
class ListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -35,7 +72,36 @@ class ListQueryDto {
|
||||
@Controller('public/v1')
|
||||
@UseGuards(ApiKeyAuthGuard)
|
||||
export class PublicApiV1Controller {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly leads: LeadsService,
|
||||
) {}
|
||||
|
||||
@Post('leads')
|
||||
@HttpCode(200)
|
||||
createLead(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
|
||||
@Body() body: CreateExternalLeadDto,
|
||||
) {
|
||||
const bucket = user.apiKeyId || user.organizationId;
|
||||
const now = Date.now();
|
||||
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
|
||||
if (prev.length >= LEAD_RATE_MAX) {
|
||||
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
prev.push(now);
|
||||
leadHits.set(bucket, prev);
|
||||
return this.leads.createFromExternal({
|
||||
organizationId: user.organizationId,
|
||||
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
mobile: body.mobile,
|
||||
storeName: body.storeName,
|
||||
source: body.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('people')
|
||||
async listPeople(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, LeadsModule],
|
||||
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
|
||||
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
|
||||
exports: [ApiKeysService, WebhooksService],
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customersPersonWhere } from '../common/data-access';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
@@ -54,7 +55,7 @@ export class ReportsService {
|
||||
const invoiceScope = ownerWhere(actor, 'finance');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const ticketScope = ticketAccessWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const personScope = customersPersonWhere(actor);
|
||||
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
@@ -1534,7 +1535,7 @@ export class ReportsService {
|
||||
const orgId = actor.organizationId;
|
||||
const dealScope = ownerWhere(actor, 'deals');
|
||||
const activityScope = assigneeWhere(actor);
|
||||
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
|
||||
const personScope = customersPersonWhere(actor);
|
||||
const rates = await loadOrgRates(this.prisma, orgId);
|
||||
const now = new Date();
|
||||
const { jy, jm } = dateToJalali(now);
|
||||
|
||||
@@ -185,6 +185,7 @@ export function toRoutineDto(
|
||||
entityLink: normalizeEntityLink(row.entityLink),
|
||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||
isActive: row.isActive,
|
||||
createdById: row.createdById,
|
||||
recurrenceLabel: describeRecurrence(rule),
|
||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||
executionTotal: execution.total,
|
||||
|
||||
@@ -200,8 +200,7 @@ export class RoutinesService {
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -216,10 +215,9 @@ export class RoutinesService {
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
@@ -232,9 +230,8 @@ export class RoutinesService {
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
@@ -251,9 +248,8 @@ export class RoutinesService {
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
@@ -268,9 +264,8 @@ export class RoutinesService {
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
this.assertCanWriteRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
@@ -295,9 +290,8 @@ export class RoutinesService {
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
@@ -345,6 +339,27 @@ export class RoutinesService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** عضو عادی فقط روتینی را مینویسد که خودش برای خودش ساخته است. */
|
||||
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
|
||||
if (scope.isManager) return dto;
|
||||
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
|
||||
throw new ForbiddenException('فقط میتوانید برای خودتان روتین بسازید');
|
||||
}
|
||||
return { ...dto, assigneeId: actor.userId, teamId: null };
|
||||
}
|
||||
|
||||
private assertCanWriteRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.isManager) {
|
||||
this.assertCanManageRoutine(scope, row);
|
||||
return;
|
||||
}
|
||||
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
|
||||
throw new ForbiddenException('فقط روتینی که برای خودتان ساختهاید را میتوانید تغییر دهید');
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
|
||||
|
||||
export type InvoicePartyPosition = {
|
||||
key: string;
|
||||
partyKind: 'person' | 'company';
|
||||
partyName: string;
|
||||
/** فاکتورهای عادی بهعلاوهٔ ماندهٔ اول دورهٔ مثبت */
|
||||
billed: Prisma.Decimal;
|
||||
/** برگشتیها و اضافهدریافت، برای ستون تسویهشدهٔ بدهکار */
|
||||
offset: Prisma.Decimal;
|
||||
/** مثبت = طلب ما، منفی = بدهی ما */
|
||||
net: Prisma.Decimal;
|
||||
};
|
||||
|
||||
type Bucket = {
|
||||
key: string;
|
||||
partyKind: 'person' | 'company';
|
||||
partyName: string;
|
||||
sales: Prisma.Decimal;
|
||||
credits: Prisma.Decimal;
|
||||
paid: Prisma.Decimal;
|
||||
opening: Prisma.Decimal;
|
||||
};
|
||||
|
||||
function zero() {
|
||||
return new Prisma.Decimal(0);
|
||||
}
|
||||
|
||||
function round2(value: Prisma.Decimal) {
|
||||
return value.toDecimalPlaces(2);
|
||||
}
|
||||
|
||||
/** همان فرمول ماندهٔ فاکتور: جمع ردیفها − تخفیف + مالیات، برگشتی منفی. */
|
||||
export function signedInvoicePayable(invoice: {
|
||||
kind: string;
|
||||
discountAmount: Prisma.Decimal;
|
||||
taxPercent: Prisma.Decimal;
|
||||
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
|
||||
}) {
|
||||
const subtotal = invoice.lineItems.reduce(
|
||||
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
|
||||
zero(),
|
||||
);
|
||||
const discount = invoice.discountAmount ?? zero();
|
||||
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
|
||||
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
|
||||
const signed = round2(subtotal.minus(discount).plus(tax));
|
||||
return invoice.kind === 'credit' ? signed.neg() : signed;
|
||||
}
|
||||
|
||||
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
|
||||
const connections = await prisma.accountingConnection.count({
|
||||
where: { organizationId, enabled: true },
|
||||
});
|
||||
return connections === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* ماندهٔ طرفحساب از فاکتور صادرشده و ماندهٔ اول دوره.
|
||||
* سند درآمد جدا ساخته نمیشود تا همان فروش دو بار طلب نشود.
|
||||
* اگر حسابداری بیرونی وصل باشد، خالی برمیگردد.
|
||||
*/
|
||||
export async function loadInvoicePartyPositions(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
): Promise<InvoicePartyPosition[]> {
|
||||
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
|
||||
|
||||
const [invoices, openings] = await Promise.all([
|
||||
prisma.invoice.findMany({
|
||||
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||
select: {
|
||||
kind: true,
|
||||
discountAmount: true,
|
||||
taxPercent: true,
|
||||
personId: true,
|
||||
companyId: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
company: { select: { name: true } },
|
||||
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||
payments: { select: { amount: true } },
|
||||
},
|
||||
}),
|
||||
prisma.partyOpeningBalance.findMany({
|
||||
where: { organizationId },
|
||||
select: {
|
||||
personId: true,
|
||||
amount: true,
|
||||
person: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const ensure = (
|
||||
key: string,
|
||||
partyKind: 'person' | 'company',
|
||||
partyName: string,
|
||||
) => {
|
||||
const current = buckets.get(key);
|
||||
if (current) return current;
|
||||
const created: Bucket = {
|
||||
key,
|
||||
partyKind,
|
||||
partyName,
|
||||
sales: zero(),
|
||||
credits: zero(),
|
||||
paid: zero(),
|
||||
opening: zero(),
|
||||
};
|
||||
buckets.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const personId = invoice.personId;
|
||||
const companyId = invoice.companyId;
|
||||
const party = personId
|
||||
? {
|
||||
key: `person:${personId}`,
|
||||
partyKind: 'person' as const,
|
||||
partyName:
|
||||
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
|
||||
'مشتری',
|
||||
}
|
||||
: companyId
|
||||
? {
|
||||
key: `company:${companyId}`,
|
||||
partyKind: 'company' as const,
|
||||
partyName: invoice.company?.name?.trim() || 'شرکت',
|
||||
}
|
||||
: null;
|
||||
if (!party) continue;
|
||||
const bucket = ensure(party.key, party.partyKind, party.partyName);
|
||||
const payable = signedInvoicePayable(invoice);
|
||||
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
|
||||
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
|
||||
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
|
||||
bucket.paid = bucket.paid.plus(paid);
|
||||
}
|
||||
|
||||
for (const opening of openings) {
|
||||
if (opening.amount.equals(0)) continue;
|
||||
const bucket = ensure(
|
||||
`person:${opening.personId}`,
|
||||
'person',
|
||||
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
|
||||
);
|
||||
bucket.opening = bucket.opening.plus(opening.amount);
|
||||
}
|
||||
|
||||
const positions: InvoicePartyPosition[] = [];
|
||||
for (const bucket of buckets.values()) {
|
||||
const net = round2(
|
||||
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
|
||||
);
|
||||
if (net.equals(0)) continue;
|
||||
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
|
||||
const offset = round2(billed.minus(net));
|
||||
positions.push({
|
||||
key: bucket.key,
|
||||
partyKind: bucket.partyKind,
|
||||
partyName: bucket.partyName,
|
||||
billed,
|
||||
offset: Prisma.Decimal.max(offset, 0),
|
||||
net,
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمیکند. */
|
||||
export async function invoiceSalesInPeriod(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
from: Date | null,
|
||||
to: Date | null,
|
||||
) {
|
||||
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
|
||||
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
|
||||
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||
select: {
|
||||
kind: true,
|
||||
discountAmount: true,
|
||||
taxPercent: true,
|
||||
issuedAt: true,
|
||||
createdAt: true,
|
||||
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let sales = zero();
|
||||
let credits = zero();
|
||||
for (const invoice of invoices) {
|
||||
const when = invoice.issuedAt ?? invoice.createdAt;
|
||||
if (from && when < from) continue;
|
||||
if (to && when > to) continue;
|
||||
const payable = signedInvoicePayable(invoice);
|
||||
if (payable.greaterThan(0)) sales = sales.plus(payable);
|
||||
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
|
||||
}
|
||||
return {
|
||||
sales: round2(sales).toFixed(2),
|
||||
credits: round2(credits).toFixed(2),
|
||||
net: round2(sales.minus(credits)).toFixed(2),
|
||||
};
|
||||
}
|
||||
@@ -1,29 +1,84 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
permissionsWithTreasuryOverride,
|
||||
treasurySectionAllowed,
|
||||
type TreasurySectionKey,
|
||||
} from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* حقوق فقط با treasury_payroll.
|
||||
* بقیه مسیرها با treasury باز میشوند؛ اگر بخش ریز انتخاب شده باشد فقط همانها.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TreasuryAccessGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: Actor;
|
||||
method?: string;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
}>();
|
||||
const actor = req.user;
|
||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||
const url = req.originalUrl || req.url || '';
|
||||
const payroll = url.includes('/treasury/payroll');
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
select: {
|
||||
orgRole: { select: { permissions: true } },
|
||||
treasuryAccessOverride: true,
|
||||
},
|
||||
});
|
||||
const permissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
const allowed = payroll
|
||||
? permissions.includes('treasury_payroll')
|
||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
||||
const permissions = permissionsWithTreasuryOverride(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
);
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (
|
||||
isLookupForDocuments(method, url) &&
|
||||
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریتشان جدا میماند. */
|
||||
function isLookupForDocuments(method: string, url: string): boolean {
|
||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
|
||||
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { invoiceSalesInPeriod } from './invoice-ledger';
|
||||
|
||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
return new Prisma.Decimal(value ?? 0);
|
||||
@@ -11,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||
function money(value: Prisma.Decimal | number | string) {
|
||||
return new Prisma.Decimal(value).toFixed(2);
|
||||
}
|
||||
function reasonText(value?: string | null) {
|
||||
const text = value?.trim() ?? '';
|
||||
return text ? text.slice(0, 500) : null;
|
||||
}
|
||||
function parseDate(iso?: string | null) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
|
||||
if (paid.lessThanOrEqualTo(0)) return 'open';
|
||||
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
|
||||
return 'partial';
|
||||
}
|
||||
function periodKey(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -35,9 +46,15 @@ export class TreasuryOpsService {
|
||||
async listPayroll(actor: Actor) {
|
||||
const rows = await this.prisma.financialPayroll.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
include: {
|
||||
movements: {
|
||||
where: { voidedAt: null },
|
||||
include: { account: { select: { name: true } } },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
},
|
||||
},
|
||||
orderBy: { dueDate: 'desc' },
|
||||
take: 100,
|
||||
take: 500,
|
||||
});
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: { id: { in: rows.map((row) => row.userId) } },
|
||||
@@ -54,8 +71,11 @@ export class TreasuryOpsService {
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
benefitsReason?: string | null;
|
||||
bonus?: string | number;
|
||||
bonusReason?: string | null;
|
||||
deductions?: string | number;
|
||||
deductionsReason?: string | null;
|
||||
dueDate?: string;
|
||||
notes?: string | null;
|
||||
},
|
||||
@@ -80,14 +100,23 @@ export class TreasuryOpsService {
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
||||
bonus,
|
||||
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
||||
deductions,
|
||||
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
||||
payable,
|
||||
dueDate: parseDate(input.dueDate) ?? new Date(),
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
include: { movements: true },
|
||||
include: {
|
||||
movements: {
|
||||
where: { voidedAt: null },
|
||||
include: { account: { select: { name: true } } },
|
||||
orderBy: { occurredAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
|
||||
return this.payrollDto(row, user?.fullName ?? '');
|
||||
@@ -123,7 +152,7 @@ export class TreasuryOpsService {
|
||||
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
|
||||
const status = payrollStatus(row.payable, paid.plus(amount));
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.create({
|
||||
data: {
|
||||
@@ -144,6 +173,123 @@ export class TreasuryOpsService {
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async updatePayroll(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: {
|
||||
userId: string;
|
||||
period: string;
|
||||
baseAmount: string | number;
|
||||
benefits?: string | number;
|
||||
benefitsReason?: string | null;
|
||||
bonus?: string | number;
|
||||
bonusReason?: string | null;
|
||||
deductions?: string | number;
|
||||
deductionsReason?: string | null;
|
||||
},
|
||||
) {
|
||||
const row = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!row || row.voidedAt || row.status === 'void') {
|
||||
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
|
||||
}
|
||||
const base = dec(input.baseAmount);
|
||||
const benefits = dec(input.benefits ?? 0);
|
||||
const bonus = dec(input.bonus ?? 0);
|
||||
const deductions = dec(input.deductions ?? 0);
|
||||
const payable = base.plus(benefits).plus(bonus).minus(deductions);
|
||||
if (!input.userId || !input.period?.trim()) {
|
||||
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
|
||||
}
|
||||
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگتر از صفر باشد.');
|
||||
const paid = this.paidOf(row.movements);
|
||||
if (payable.lessThan(paid)) {
|
||||
throw new BadRequestException('مبلغ قابل پرداخت از پرداختهای ثبتشده کمتر است.');
|
||||
}
|
||||
const member = await this.prisma.membership.findFirst({
|
||||
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
|
||||
});
|
||||
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
|
||||
try {
|
||||
await this.prisma.financialPayroll.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
userId: input.userId,
|
||||
period: input.period.trim(),
|
||||
baseAmount: base,
|
||||
benefits,
|
||||
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
|
||||
bonus,
|
||||
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
|
||||
deductions,
|
||||
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
|
||||
payable,
|
||||
status: payrollStatus(payable, paid),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async updatePayrollPayment(
|
||||
actor: Actor,
|
||||
payrollId: string,
|
||||
movementId: string,
|
||||
input: { amount: string | number; accountId: string; occurredAt?: string },
|
||||
) {
|
||||
const movement = await this.prisma.financialMovement.findFirst({
|
||||
where: {
|
||||
id: movementId,
|
||||
payrollId,
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
},
|
||||
});
|
||||
const payroll = await this.prisma.financialPayroll.findFirst({
|
||||
where: { id: payrollId, organizationId: actor.organizationId },
|
||||
include: { movements: true },
|
||||
});
|
||||
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
|
||||
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
|
||||
}
|
||||
const amount = dec(input.amount);
|
||||
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
|
||||
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
|
||||
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
|
||||
}
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!account || (!account.isActive && account.id !== movement.accountId)) {
|
||||
throw new BadRequestException('حساب پرداخت پیدا نشد.');
|
||||
}
|
||||
const available = await this.balance(account.id);
|
||||
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
|
||||
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.financialMovement.update({
|
||||
where: { id: movement.id },
|
||||
data: {
|
||||
accountId: account.id,
|
||||
amount,
|
||||
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
|
||||
},
|
||||
}),
|
||||
this.prisma.financialPayroll.update({
|
||||
where: { id: payroll.id },
|
||||
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
|
||||
}),
|
||||
]);
|
||||
return this.listPayroll(actor);
|
||||
}
|
||||
|
||||
async listRecurring(actor: Actor) {
|
||||
const rows = await this.prisma.financialRecurringItem.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
@@ -314,13 +460,20 @@ export class TreasuryOpsService {
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
async reportExcel(
|
||||
actor: Actor,
|
||||
from?: string,
|
||||
to?: string,
|
||||
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
|
||||
) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
|
||||
const rows: Array<[string, string, string]> = [
|
||||
['درآمد ثبتشده', data.current.bookedIncome, data.previous.bookedIncome],
|
||||
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
|
||||
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
|
||||
['هزینه ثبتشده', data.current.bookedExpense, data.previous.bookedExpense],
|
||||
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
|
||||
['ورود وجه', data.current.cashIn, data.previous.cashIn],
|
||||
@@ -331,10 +484,41 @@ export class TreasuryOpsService {
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
if (parties) {
|
||||
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافتشده');
|
||||
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداختشده');
|
||||
}
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private addPartySheet(
|
||||
book: ExcelJS.Workbook,
|
||||
title: string,
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
settledLabel: string,
|
||||
) {
|
||||
const sheet = book.addWorksheet(title);
|
||||
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
|
||||
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
|
||||
let amount = new Prisma.Decimal(0);
|
||||
let settled = new Prisma.Decimal(0);
|
||||
let remaining = new Prisma.Decimal(0);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
]);
|
||||
amount = amount.plus(row.amount);
|
||||
settled = settled.plus(row.settledAmount);
|
||||
remaining = remaining.plus(row.remaining);
|
||||
}
|
||||
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
@@ -374,10 +558,15 @@ export class TreasuryOpsService {
|
||||
amount: money(amount),
|
||||
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
|
||||
}));
|
||||
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
|
||||
return {
|
||||
bookedIncome: money(bookedIncome),
|
||||
invoiceSales: invoiceSales.sales,
|
||||
invoiceCredits: invoiceSales.credits,
|
||||
bookedExpense: money(bookedExpense),
|
||||
operatingProfit: money(bookedIncome.minus(bookedExpense)),
|
||||
operatingProfit: money(
|
||||
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
|
||||
),
|
||||
cashIn: money(cashIn),
|
||||
cashOut: money(cashOut),
|
||||
cashNet: money(cashIn.minus(cashOut)),
|
||||
@@ -438,12 +627,22 @@ export class TreasuryOpsService {
|
||||
period: string;
|
||||
baseAmount: Prisma.Decimal;
|
||||
benefits: Prisma.Decimal;
|
||||
benefitsReason?: string | null;
|
||||
bonus: Prisma.Decimal;
|
||||
bonusReason?: string | null;
|
||||
deductions: Prisma.Decimal;
|
||||
deductionsReason?: string | null;
|
||||
payable: Prisma.Decimal;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||
movements: Array<{
|
||||
id: string;
|
||||
amount: Prisma.Decimal;
|
||||
voidedAt: Date | null;
|
||||
accountId: string;
|
||||
occurredAt: Date;
|
||||
account?: { name: string } | null;
|
||||
}>;
|
||||
},
|
||||
fullName: string,
|
||||
) {
|
||||
@@ -456,13 +655,25 @@ export class TreasuryOpsService {
|
||||
period: row.period,
|
||||
baseAmount: money(row.baseAmount),
|
||||
benefits: money(row.benefits),
|
||||
benefitsReason: row.benefitsReason?.trim() || null,
|
||||
bonus: money(row.bonus),
|
||||
bonusReason: row.bonusReason?.trim() || null,
|
||||
deductions: money(row.deductions),
|
||||
deductionsReason: row.deductionsReason?.trim() || null,
|
||||
payable: money(row.payable),
|
||||
paidAmount: money(paid),
|
||||
remaining: money(left.isNegative() ? 0 : left),
|
||||
dueDate: row.dueDate.toISOString(),
|
||||
status: row.status,
|
||||
payments: row.movements
|
||||
.filter((item) => !item.voidedAt)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
amount: money(item.amount),
|
||||
occurredAt: item.occurredAt.toISOString(),
|
||||
accountId: item.accountId,
|
||||
accountName: item.account?.name ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -61,7 +75,18 @@ export class TreasuryController {
|
||||
updateAccount(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
type?: 'bank' | 'cash' | 'petty';
|
||||
bankName?: string | null;
|
||||
accountRef?: string | null;
|
||||
notes?: string | null;
|
||||
isActive?: boolean;
|
||||
responsibleName?: string | null;
|
||||
openingBalance?: string | number;
|
||||
openingDate?: string;
|
||||
},
|
||||
) {
|
||||
return this.treasury.updateAccount(user, id, body);
|
||||
}
|
||||
@@ -81,6 +106,52 @@ export class TreasuryController {
|
||||
return this.treasury.transfer(user, body);
|
||||
}
|
||||
|
||||
@Get('parties')
|
||||
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
|
||||
return this.treasury.searchParties(user, q);
|
||||
}
|
||||
|
||||
@Post('parties/person')
|
||||
createPartyPerson(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { firstName?: string; lastName?: string },
|
||||
) {
|
||||
return this.treasury.createPartyPerson(user, body);
|
||||
}
|
||||
|
||||
@Post('parties/company')
|
||||
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
|
||||
return this.treasury.createPartyCompany(user, body);
|
||||
}
|
||||
|
||||
@Get('party-balances')
|
||||
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
|
||||
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
|
||||
}
|
||||
|
||||
@Post('party-balances/collect')
|
||||
collectPartyBalance(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body()
|
||||
body: {
|
||||
kind?: string;
|
||||
partyKind?: 'person' | 'company' | 'user';
|
||||
partyId?: string;
|
||||
amount?: string | number;
|
||||
accountId?: string;
|
||||
occurredAt?: string;
|
||||
},
|
||||
) {
|
||||
return this.treasury.collectPartyBalance(user, {
|
||||
kind: body.kind === 'expense' ? 'expense' : 'income',
|
||||
partyKind: body.partyKind ?? 'person',
|
||||
partyId: body.partyId ?? '',
|
||||
amount: body.amount ?? 0,
|
||||
accountId: body.accountId ?? '',
|
||||
occurredAt: body.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -184,6 +255,25 @@ export class TreasuryController {
|
||||
return this.ops.createPayroll(user, body);
|
||||
}
|
||||
|
||||
@Patch('payroll/:id')
|
||||
updatePayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
|
||||
) {
|
||||
return this.ops.updatePayroll(user, id, body);
|
||||
}
|
||||
|
||||
@Patch('payroll/:id/payments/:movementId')
|
||||
updatePayrollPayment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Param('movementId') movementId: string,
|
||||
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
|
||||
) {
|
||||
return this.ops.updatePayrollPayment(user, id, movementId, body);
|
||||
}
|
||||
|
||||
@Post('payroll/:id/pay')
|
||||
payPayroll(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -221,7 +311,11 @@ export class TreasuryController {
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
const [debtors, creditors] = await Promise.all([
|
||||
this.treasury.partyBalances(user, 'income'),
|
||||
this.treasury.partyBalances(user, 'expense'),
|
||||
]);
|
||||
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ContactsModule } from '../contacts/contacts.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
imports: [AuthModule, NotificationsModule, ContactsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
|
||||
/** null = ارث از نقش */
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
UpdateUserVisibilityRequest,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
|
||||
import {
|
||||
DATA_SCOPES,
|
||||
DATA_VISIBILITIES,
|
||||
parseDataAccessPolicy,
|
||||
parseUserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import { resolveAllSectionAccess } from '../common/data-access';
|
||||
import { parseVisibilityScopes } from '../common/record-scope';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -47,6 +52,7 @@ type MembershipRow = {
|
||||
dataVisibility?: string | null;
|
||||
dataVisibilityScopes?: unknown;
|
||||
dataAccessOverride?: unknown;
|
||||
treasuryAccessOverride?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
@@ -98,8 +104,10 @@ export class UsersService {
|
||||
orgRoleId: dto.orgRoleId,
|
||||
role: dto.role,
|
||||
});
|
||||
if (resolved.role === 'owner' && actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
||||
if (resolved.role === 'owner') {
|
||||
throw new BadRequestException(
|
||||
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبتنام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
@@ -370,23 +378,35 @@ export class UsersService {
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
targetId: string,
|
||||
dto: { dataAccessOverride?: Record<string, unknown> | null },
|
||||
dto: {
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<OrgUserDto> {
|
||||
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
||||
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
|
||||
}
|
||||
if (dto.dataAccessOverride === undefined) {
|
||||
if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
|
||||
throw new BadRequestException('تغییری ارسال نشده است');
|
||||
}
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
if (target.role === 'owner' || target.role === 'admin') {
|
||||
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
|
||||
}
|
||||
const next =
|
||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||
const data: Prisma.MembershipUpdateInput = {};
|
||||
if (dto.dataAccessOverride !== undefined) {
|
||||
data.dataAccessOverride =
|
||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||
}
|
||||
if (dto.treasuryAccessOverride !== undefined) {
|
||||
data.treasuryAccessOverride =
|
||||
dto.treasuryAccessOverride === null
|
||||
? Prisma.DbNull
|
||||
: (parseUserTreasuryAccess(dto.treasuryAccessOverride) ?? Prisma.DbNull);
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { dataAccessOverride: next },
|
||||
data,
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
@@ -431,6 +451,10 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRole === 'owner' && target.role !== 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, target.userId);
|
||||
}
|
||||
|
||||
if (target.role === 'owner' && nextRole !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
organizationId,
|
||||
@@ -438,7 +462,7 @@ export class UsersService {
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -510,6 +534,9 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
if (isActive && !target.isActive) {
|
||||
if (target.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, targetId);
|
||||
}
|
||||
await this.billing.assertSeatAvailable(organizationId, targetId);
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
@@ -540,6 +567,9 @@ export class UsersService {
|
||||
resolved: { role: UserRole; orgRoleId: string },
|
||||
): Promise<OrgUserDto> {
|
||||
await this.billing.assertSeatAvailable(organizationId, userId);
|
||||
if (resolved.role === 'owner') {
|
||||
await this.memberships.assertSoleOwner(organizationId, userId);
|
||||
}
|
||||
const existing = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId },
|
||||
});
|
||||
@@ -606,6 +636,10 @@ export class UsersService {
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? {}
|
||||
: parseDataAccessPolicy(row.dataAccessOverride),
|
||||
treasuryAccessOverride:
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? null
|
||||
: parseUserTreasuryAccess(row.treasuryAccessOverride),
|
||||
...(() => {
|
||||
const effectiveDataAccess = resolveAllSectionAccess({
|
||||
role: row.role,
|
||||
|
||||
@@ -3,13 +3,16 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessSection,
|
||||
FinancialAccountDto,
|
||||
PersonImportSourceStatDto,
|
||||
RolePermissionKey,
|
||||
SectionDataAccess,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
emptySectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -21,11 +24,16 @@ const props = defineProps<{
|
||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||
enabledPermissions?: RolePermissionKey[];
|
||||
disabled?: boolean;
|
||||
/** تبهای عمق داده قفل شوند؛ تب مدیریت مالی جدا میماند */
|
||||
dataLocked?: boolean;
|
||||
showTreasury?: boolean;
|
||||
treasuryAccess?: UserTreasuryAccess | null;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [DataAccessPolicy];
|
||||
'update:treasuryAccess': [UserTreasuryAccess | null];
|
||||
}>();
|
||||
|
||||
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
||||
@@ -47,12 +55,13 @@ const visibleSections = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const active = ref<DataAccessSection>('customers');
|
||||
const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
|
||||
const active = ref<DataAccessSection | 'treasury'>('customers');
|
||||
|
||||
watch(
|
||||
visibleSections,
|
||||
(list) => {
|
||||
if (!list.includes(active.value) && list[0]) active.value = list[0];
|
||||
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -89,6 +98,48 @@ const activityTypes = ref<IdOption[]>([]);
|
||||
const projectBoards = ref<IdOption[]>([]);
|
||||
const projectStages = ref<IdOption[]>([]);
|
||||
const routineCategories = ref<IdOption[]>([]);
|
||||
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
|
||||
const treasuryAccountQuery = ref('');
|
||||
|
||||
const filteredTreasuryAccounts = computed(() => {
|
||||
const query = treasuryAccountQuery.value.trim();
|
||||
if (!query) return treasuryAccounts.value;
|
||||
return treasuryAccounts.value.filter((account) => account.name.includes(query));
|
||||
});
|
||||
|
||||
function emptyTreasuryAccess(): UserTreasuryAccess {
|
||||
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
|
||||
}
|
||||
|
||||
function patchTreasury(next: UserTreasuryAccess | null) {
|
||||
emit('update:treasuryAccess', next);
|
||||
}
|
||||
|
||||
function toggleTreasurySection(key: RolePermissionKey) {
|
||||
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||
const sections = current.sections.includes(key)
|
||||
? current.sections.filter((item) => item !== key)
|
||||
: [...current.sections, key];
|
||||
patchTreasury({ ...current, enabled: true, sections });
|
||||
}
|
||||
|
||||
function toggleTreasuryAccount(id: string) {
|
||||
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||
const accountIds = current.accountIds.includes(id)
|
||||
? current.accountIds.filter((item) => item !== id)
|
||||
: [...current.accountIds, id];
|
||||
patchTreasury({ ...current, enabled: true, accountIds });
|
||||
}
|
||||
|
||||
async function loadTreasuryAccounts() {
|
||||
if (!props.showTreasury) return;
|
||||
try {
|
||||
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||
treasuryAccounts.value = data;
|
||||
} catch {
|
||||
treasuryAccounts.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
await loadCustomerGroups().catch(() => undefined);
|
||||
@@ -176,8 +227,13 @@ async function loadOptions() {
|
||||
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
void loadTreasuryAccounts();
|
||||
});
|
||||
|
||||
const editorSection = computed<DataAccessSection>(() =>
|
||||
active.value === 'treasury' ? 'customers' : active.value,
|
||||
);
|
||||
|
||||
const stagesForActive = computed(() => {
|
||||
if (active.value === 'leads') return leadStages.value;
|
||||
if (active.value === 'sales') return dealStages.value;
|
||||
@@ -218,11 +274,94 @@ const typesForActive = computed(() => {
|
||||
>
|
||||
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showTreasury"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: active === 'treasury' }"
|
||||
role="tab"
|
||||
:aria-selected="active === 'treasury'"
|
||||
@click="active = 'treasury'"
|
||||
>
|
||||
مدیریت مالی
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="active === 'treasury'" class="treasury">
|
||||
<p class="muted">
|
||||
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
||||
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
||||
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
||||
</p>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="user-treasury"
|
||||
:checked="treasuryAccess == null"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury(null)"
|
||||
/>
|
||||
مثل نقش
|
||||
</label>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="user-treasury"
|
||||
:checked="treasuryAccess != null"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
|
||||
/>
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<template v-if="treasuryAccess">
|
||||
<label class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.enabled"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
||||
/>
|
||||
دسترسی به مدیریت مالی
|
||||
</label>
|
||||
<template v-if="treasuryAccess.enabled">
|
||||
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.sections.includes(perm.key)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasurySection(perm.key)"
|
||||
/>
|
||||
{{ perm.label }}
|
||||
</label>
|
||||
<label class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.ownAccounts"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
|
||||
/>
|
||||
حسابهایی که خودش مسئول آنهاست
|
||||
</label>
|
||||
<p class="muted">حسابهای دیگر</p>
|
||||
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
|
||||
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="treasuryAccess.accountIds.includes(account.id)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasuryAccount(account.id)"
|
||||
/>
|
||||
{{ account.name }}
|
||||
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||
</label>
|
||||
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<DataAccessEditor
|
||||
v-else
|
||||
:key="active"
|
||||
:section="active"
|
||||
:model-value="sectionValue(active)"
|
||||
:section="editorSection"
|
||||
:model-value="sectionValue(editorSection)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:pipelines="pipelines"
|
||||
@@ -231,9 +370,9 @@ const typesForActive = computed(() => {
|
||||
:import-sources="importSources"
|
||||
:import-sources-ready="importSourcesReady"
|
||||
:import-sources-failed="importSourcesFailed"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || dataLocked"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(active, $event)"
|
||||
@update:model-value="setSection(editorSection, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
@@ -268,4 +407,17 @@ const typesForActive = computed(() => {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.treasury {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.choice.sub {
|
||||
padding-inline-start: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = withDefaults(
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
icon?: 'calendar' | 'clock' | 'bell';
|
||||
/** اگر داده شود، متن باکس بهجای تاریخ خام همین مقدار است. */
|
||||
displayText?: string;
|
||||
/** نمایی که با باز شدن پنل دیده میشود. */
|
||||
startView?: 'days' | 'months' | 'years';
|
||||
}>(),
|
||||
{
|
||||
mode: 'date',
|
||||
@@ -37,6 +41,8 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
icon: undefined,
|
||||
displayText: '',
|
||||
startView: 'days',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
|
||||
});
|
||||
|
||||
const display = computed(() => {
|
||||
if (props.displayText) return props.displayText;
|
||||
if (props.mode === 'time') {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
|
||||
if (!match) return '';
|
||||
@@ -353,7 +360,7 @@ function place() {
|
||||
|
||||
function liftAboveOverlays() {
|
||||
let z = 120;
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
|
||||
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
|
||||
}
|
||||
@@ -368,7 +375,7 @@ function onWindowChange() {
|
||||
async function show() {
|
||||
if (props.disabled) return;
|
||||
syncDraftFrom(parsed.value);
|
||||
calView.value = 'days';
|
||||
calView.value = props.startView;
|
||||
clockFace.value = 'hour';
|
||||
syncViewport();
|
||||
liftAboveOverlays();
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = withDefaults(
|
||||
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
||||
asNumber?: boolean;
|
||||
dir?: 'ltr' | 'rtl';
|
||||
addonAfter?: string;
|
||||
}>(),
|
||||
{
|
||||
required: false,
|
||||
@@ -30,6 +31,7 @@ const props = withDefaults(
|
||||
decimals: 2,
|
||||
asNumber: false,
|
||||
dir: 'ltr',
|
||||
addonAfter: '',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:value="text"
|
||||
:addon-after="addonAfter || undefined"
|
||||
style="width: 100%; font-variant-numeric: tabular-nums"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
||||
@@ -126,6 +126,7 @@ defineExpose({ refresh: load });
|
||||
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="chargeOpen" class="layer">
|
||||
<div class="backdrop" @click="closeCharge" />
|
||||
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
|
||||
@@ -170,7 +171,9 @@ defineExpose({ refresh: load });
|
||||
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="warnOpen" class="layer">
|
||||
<div class="backdrop" @click="warnOpen = false" />
|
||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||
@@ -186,6 +189,7 @@ defineExpose({ refresh: load });
|
||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,9 +279,12 @@ defineExpose({ refresh: load });
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
@@ -289,7 +296,8 @@ defineExpose({ refresh: load });
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 640px);
|
||||
max-height: calc(100vh - 2rem);
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
|
||||
import { LeftOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export type StagePathItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
stages: StagePathItem[];
|
||||
value?: string | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
value: null,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
ariaLabel: 'مراحل',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [id: string];
|
||||
select: [id: string];
|
||||
}>();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
props.stages.findIndex((stage) => stage.id === props.value),
|
||||
);
|
||||
|
||||
const stripStyle = computed(() => ({
|
||||
width: '100%',
|
||||
overflowX: 'auto' as const,
|
||||
paddingBottom: '2px',
|
||||
}));
|
||||
|
||||
const sepStyle = computed(() => ({
|
||||
color: token.value.colorTextQuaternary,
|
||||
fontSize: '10px',
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
function onPick(id: string) {
|
||||
if (props.disabled || props.loading || id === props.value) return;
|
||||
emit('update:value', id);
|
||||
emit('select', id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="listbox"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-busy="loading || undefined"
|
||||
:style="stripStyle"
|
||||
>
|
||||
<a-flex align="center" :gap="6" wrap="nowrap" :style="{ width: 'max-content', minWidth: '100%' }">
|
||||
<template v-for="(stage, index) in stages" :key="stage.id">
|
||||
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
|
||||
<a-button
|
||||
role="option"
|
||||
:aria-selected="stage.id === value"
|
||||
:type="stage.id === value ? 'primary' : 'default'"
|
||||
:disabled="disabled || loading"
|
||||
:style="{
|
||||
borderRadius: '999px',
|
||||
flexShrink: 0,
|
||||
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
|
||||
opacity: index < currentIndex ? 0.85 : 1,
|
||||
}"
|
||||
@click="onPick(stage.id)"
|
||||
>
|
||||
{{ stage.name }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TeamDto,
|
||||
TeamMemberRole,
|
||||
UpdatePriorArchiveCutoffRequest,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
@@ -279,8 +280,13 @@ async function removeTeam(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const showTreasury = computed(() => {
|
||||
const modules = auth.user?.enabledModules;
|
||||
return !modules || modules.includes('treasury');
|
||||
});
|
||||
const expandedAccess = ref<string | null>(null);
|
||||
const draftAccess = ref<DataAccessPolicy>({});
|
||||
const draftTreasury = ref<UserTreasuryAccess | null>(null);
|
||||
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
||||
const accessSaving = ref(false);
|
||||
|
||||
@@ -323,6 +329,7 @@ function openAccessEditor(u: OrgUserDto) {
|
||||
draftAccess.value = hasOverride
|
||||
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
||||
: clonePolicy(u.effectiveDataAccess);
|
||||
draftTreasury.value = u.treasuryAccessOverride ?? null;
|
||||
expandedAccess.value = u.id;
|
||||
}
|
||||
|
||||
@@ -333,8 +340,8 @@ async function saveUserAccess(userId: string) {
|
||||
try {
|
||||
const payload =
|
||||
accessMode.value === 'inherit'
|
||||
? { dataAccessOverride: null }
|
||||
: { dataAccessOverride: draftAccess.value };
|
||||
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
|
||||
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
|
||||
await api.patch(`/users/${userId}/data-access`, payload);
|
||||
ok.value = 'محدوده داده بهروز شد.';
|
||||
expandedAccess.value = null;
|
||||
@@ -528,8 +535,10 @@ onMounted(() => {
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<DataAccessPolicyPanel
|
||||
v-if="accessMode === 'custom'"
|
||||
v-model="draftAccess"
|
||||
v-model:treasury-access="draftTreasury"
|
||||
:show-treasury="showTreasury"
|
||||
:data-locked="accessMode === 'inherit'"
|
||||
compact
|
||||
/>
|
||||
<div class="actions">
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type PickUser = { id: string; fullName: string };
|
||||
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
|
||||
type AssigneeGroup = { id: string; name: string; userIds: string[] };
|
||||
type GroupKind = 'role' | 'team';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
users: PickUser[];
|
||||
/** انتخاب یک نقش، همهٔ اعضایش را مسئول میکند */
|
||||
roles?: AssigneeGroup[];
|
||||
/** انتخاب یک تیم، همهٔ اعضایش را مسئول میکند */
|
||||
teams?: AssigneeGroup[];
|
||||
disabled?: boolean;
|
||||
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال میشود) */
|
||||
min?: number;
|
||||
addLabel?: string;
|
||||
emptyLabel?: string;
|
||||
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمعکردن برچسبها وقتی تعداد زیاد است */
|
||||
selectAll?: boolean;
|
||||
}>(),
|
||||
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
|
||||
{
|
||||
roles: () => [],
|
||||
teams: () => [],
|
||||
disabled: false,
|
||||
min: 0,
|
||||
addLabel: 'افزودن',
|
||||
emptyLabel: 'کسی انتخاب نشده',
|
||||
selectAll: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,32 +39,208 @@ const emit = defineEmits<{
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const options = computed(() =>
|
||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
const pinned = ref(new Set<string>());
|
||||
|
||||
function uniqueIds(ids: string[]) {
|
||||
return [...new Set(ids.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string) {
|
||||
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
const catalog = computed(() => {
|
||||
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
|
||||
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
|
||||
for (const group of groups) {
|
||||
const userIds = uniqueIds(group.userIds);
|
||||
if (!userIds.length || !group.id || !group.name) continue;
|
||||
rows.push({
|
||||
token: `${kind}:${group.id}`,
|
||||
name: group.name,
|
||||
userIds,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
};
|
||||
push('role', props.roles);
|
||||
push('team', props.teams);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
|
||||
|
||||
function covers(userIds: string[], selected: string[]) {
|
||||
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
|
||||
}
|
||||
|
||||
const shownGroups = computed(() =>
|
||||
catalog.value.filter((group) => {
|
||||
if (!covers(group.userIds, props.modelValue)) return false;
|
||||
if (group.userIds.length >= 2) return true;
|
||||
return pinned.value.has(group.token);
|
||||
}),
|
||||
);
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
const ids = Array.isArray(value) ? value.map(String) : [];
|
||||
const selectValue = computed(() => {
|
||||
const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
|
||||
return [
|
||||
...shownGroups.value.map((group) => group.token),
|
||||
...props.modelValue.filter((id) => !covered.has(id)),
|
||||
];
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
const people = props.users.map((user) => ({
|
||||
value: user.id,
|
||||
label:
|
||||
catalog.value.length && user.orgRoleName
|
||||
? `${user.fullName} — ${user.orgRoleName}`
|
||||
: user.fullName,
|
||||
}));
|
||||
if (!catalog.value.length) return people;
|
||||
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
|
||||
const section = (kind: GroupKind, title: string) => {
|
||||
const items = catalog.value
|
||||
.filter((group) => group.kind === kind)
|
||||
.map((group) => ({
|
||||
value: group.token,
|
||||
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
|
||||
}));
|
||||
if (items.length) grouped.push({ label: title, options: items });
|
||||
};
|
||||
section('role', 'نقشها');
|
||||
section('team', 'تیمها');
|
||||
if (people.length) grouped.push({ label: 'اعضا', options: people });
|
||||
return grouped;
|
||||
});
|
||||
|
||||
function filterOption(input: string, option?: { label?: unknown }) {
|
||||
const label = String(option?.label ?? '');
|
||||
if (!label || !input.trim()) return true;
|
||||
return normalizeSearch(label).includes(normalizeSearch(input));
|
||||
}
|
||||
|
||||
const allPicked = computed(
|
||||
() =>
|
||||
props.users.length > 0 &&
|
||||
props.users.every((user) => props.modelValue.includes(user.id)),
|
||||
);
|
||||
|
||||
const tagLimit = computed(() => {
|
||||
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function tagPlaceholder(omitted: unknown[]) {
|
||||
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
|
||||
const extra = Array.isArray(omitted) ? omitted.length : 0;
|
||||
if (!extra) return '';
|
||||
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue.join('\0'),
|
||||
() => {
|
||||
const next = new Set<string>();
|
||||
for (const token of pinned.value) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group && covers(group.userIds, props.modelValue)) next.add(token);
|
||||
}
|
||||
pinned.value = next;
|
||||
},
|
||||
);
|
||||
|
||||
function sameIds(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
const have = new Set(left);
|
||||
return right.every((id) => have.has(id));
|
||||
}
|
||||
|
||||
function commit(ids: string[]) {
|
||||
if (ids.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', ids);
|
||||
emit('change', ids);
|
||||
if (!sameIds(ids, props.modelValue)) emit('change', ids);
|
||||
}
|
||||
|
||||
function onUpdate(value: unknown) {
|
||||
const tokens = Array.isArray(value) ? value.map(String) : [];
|
||||
const tokenSet = new Set(tokens);
|
||||
const removedMemberIds = new Set<string>();
|
||||
for (const token of selectValue.value) {
|
||||
if (tokenSet.has(token)) continue;
|
||||
const dropped = catalogByToken.value.get(token);
|
||||
if (!dropped) continue;
|
||||
for (const id of dropped.userIds) removedMemberIds.add(id);
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pins = new Set<string>();
|
||||
for (const token of tokens) {
|
||||
const group = catalogByToken.value.get(token);
|
||||
if (group) {
|
||||
pins.add(token);
|
||||
for (const id of group.userIds) {
|
||||
if (removedMemberIds.has(id)) continue;
|
||||
ids.add(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
|
||||
}
|
||||
const next = [...ids];
|
||||
if (next.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
pinned.value = pins;
|
||||
commit(next);
|
||||
}
|
||||
|
||||
function pickAll() {
|
||||
if (props.disabled || allPicked.value) return;
|
||||
commit(props.users.map((user) => user.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:value="modelValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
:value="selectValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
:filter-option="filterOption"
|
||||
:max-tag-count="tagLimit"
|
||||
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
|
||||
همه
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-multi.with-all {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-multi.with-all :deep(.ant-select) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-multi.with-all :deep(.ant-btn) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
|
||||
import {
|
||||
ACTIVITY_BOARD_COPY,
|
||||
initialsFromTitle,
|
||||
@@ -41,6 +42,7 @@ const emit = defineEmits<{
|
||||
stagesChanged: [];
|
||||
add: [stageId: string];
|
||||
error: [message: string];
|
||||
notice: [message: string];
|
||||
menuDeal: [activity: ActivityDto, stageId?: string];
|
||||
toggleSelect: [activityId: string];
|
||||
bulkDelete: [];
|
||||
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
|
||||
const overlayDeal = ref<PipelineDeal | null>(null);
|
||||
const overlayAccent = ref<PipelineAccent>('green');
|
||||
const overlayPos = ref({ x: 0, y: 0 });
|
||||
const quickId = ref<string | null>(null);
|
||||
const peekId = ref<string | null>(null);
|
||||
let peekLinger = 0;
|
||||
const moving = ref(false);
|
||||
const stageBusy = ref(false);
|
||||
const localOrder = ref<string[] | null>(null);
|
||||
/** صف جابهجایی تا دراپ پشتسرهم قفل نشود؛ در watchdog هم ریست میشود */
|
||||
let moveChain: Promise<void> = Promise.resolve();
|
||||
let moveWatchdog = 0;
|
||||
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
|
||||
let dragSessionId: string | null = null;
|
||||
let dragVisualTimer = 0;
|
||||
|
||||
function clearDragState() {
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = null;
|
||||
}
|
||||
|
||||
function resetMoveLock() {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moving.value = false;
|
||||
moveChain = Promise.resolve();
|
||||
}
|
||||
|
||||
function armMoveWatchdog() {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moveWatchdog = window.setTimeout(() => {
|
||||
resetMoveLock();
|
||||
}, 12_000);
|
||||
}
|
||||
|
||||
function enqueueMove(task: () => Promise<void>) {
|
||||
const run = moveChain.then(task, task);
|
||||
moveChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
|
||||
open: undefined,
|
||||
@@ -120,6 +162,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
assigneeNames,
|
||||
ownerId: item.assignee?.id ?? null,
|
||||
assigneeIds: (item.assignees ?? []).map((a) => a.id),
|
||||
priority: item.priority,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,12 +264,12 @@ function onMenuActivity(id: string) {
|
||||
}
|
||||
|
||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
const sid = findStageOf(deal.id);
|
||||
overlayAccent.value =
|
||||
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
|
||||
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
|
||||
draggingColumnId.value = null;
|
||||
// قفل/صف مانده از درخواست قبلی را باز کن
|
||||
if (moving.value) resetMoveLock();
|
||||
|
||||
dragSessionId = deal.id;
|
||||
// setData / setDragImage باید همزمان با dragstart باشند
|
||||
event.dataTransfer?.setData('text/plain', deal.id);
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
@@ -234,12 +277,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
event.dataTransfer.setDragImage(img, 0, 0);
|
||||
}
|
||||
|
||||
const sid = findStageOf(deal.id);
|
||||
overlayAccent.value =
|
||||
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
|
||||
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
|
||||
|
||||
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو میکند
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragVisualTimer = window.setTimeout(() => {
|
||||
if (dragSessionId !== deal.id) return;
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
if (quickId.value) closeQuick();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
const endedId = dragSessionId ?? draggingId.value;
|
||||
window.setTimeout(() => {
|
||||
if (dragSessionId && dragSessionId !== endedId) return;
|
||||
if (draggingId.value && draggingId.value !== endedId) return;
|
||||
clearDragState();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onColumnDragStart(id: string) {
|
||||
window.clearTimeout(dragVisualTimer);
|
||||
dragSessionId = null;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
overlayDeal.value = null;
|
||||
draggingColumnId.value = id;
|
||||
}
|
||||
|
||||
function onColumnDragEnd() {
|
||||
draggingColumnId.value = null;
|
||||
dropStageId.value = null;
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
@@ -253,60 +326,100 @@ function onDragOver(stageId: string, event: DragEvent) {
|
||||
|
||||
async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||
const fromStageId = findStageOf(activityId);
|
||||
if (!fromStageId || fromStageId === toStageId) return;
|
||||
const from = columns.value[fromStageId]!;
|
||||
if (!fromStageId) return;
|
||||
const from = columns.value[fromStageId];
|
||||
const to = columns.value[toStageId];
|
||||
if (!to) return;
|
||||
const item = from.items.find((i) => i.id === activityId)!;
|
||||
const toStage = props.stages.find((s) => s.id === toStageId)!;
|
||||
if (!from || !to) return;
|
||||
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);
|
||||
if (!toStage) return;
|
||||
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 ? without.length : index;
|
||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||
if (same && insertAt === fromIndex) return;
|
||||
|
||||
const snapshot = {
|
||||
fromItems: from.items,
|
||||
fromTotal: from.total,
|
||||
toItems: to.items,
|
||||
toTotal: to.total,
|
||||
};
|
||||
const prevFrom = from.items;
|
||||
const prevTo = to.items;
|
||||
const prevFromTotal = from.total;
|
||||
const prevToTotal = to.total;
|
||||
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
|
||||
from.items = from.items.filter((i) => i.id !== activityId);
|
||||
from.total = Math.max(0, from.total - 1);
|
||||
const nextTo = [...to.items];
|
||||
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
|
||||
nextTo.splice(at, 0, optimistic);
|
||||
to.items = nextTo;
|
||||
to.total += 1;
|
||||
const nextTo = [...without];
|
||||
nextTo.splice(insertAt, 0, optimistic);
|
||||
if (same) {
|
||||
from.items = nextTo;
|
||||
} else {
|
||||
from.items = from.items.filter((i) => i.id !== activityId);
|
||||
from.total = Math.max(0, from.total - 1);
|
||||
to.items = nextTo;
|
||||
to.total += 1;
|
||||
}
|
||||
|
||||
moving.value = true;
|
||||
armMoveWatchdog();
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
||||
stageId: toStageId,
|
||||
position: insertAt,
|
||||
});
|
||||
to.items = to.items.map((i) => (i.id === activityId ? data : i));
|
||||
if (!same && data.status !== toStage.kind) {
|
||||
const hideFromMine = props.params.desk === 'mine';
|
||||
from.items = hideFromMine
|
||||
? prevFrom.filter((row) => row.id !== activityId)
|
||||
: prevFrom.map((row) => (row.id === activityId ? data : row));
|
||||
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
|
||||
to.items = prevTo;
|
||||
to.total = prevToTotal;
|
||||
emit(
|
||||
'notice',
|
||||
'سهم شما ثبت شد. فعالیت وقتی انجامشده میشود که همهٔ مسئولها سهم خود را بزنند.',
|
||||
);
|
||||
emit('changed');
|
||||
return;
|
||||
}
|
||||
const column = same ? from : to;
|
||||
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
from.items = snapshot.fromItems;
|
||||
from.total = snapshot.fromTotal;
|
||||
to.items = snapshot.toItems;
|
||||
to.total = snapshot.toTotal;
|
||||
from.items = prevFrom;
|
||||
from.total = prevFromTotal;
|
||||
if (!same) {
|
||||
to.items = prevTo;
|
||||
to.total = prevToTotal;
|
||||
}
|
||||
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
||||
} finally {
|
||||
window.clearTimeout(moveWatchdog);
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
|
||||
dropStageId.value = null;
|
||||
draggingId.value = null;
|
||||
overlayDeal.value = null;
|
||||
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
||||
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
||||
draggingColumnId.value = null;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
const transfer = event.dataTransfer?.getData('text/plain') || '';
|
||||
const cardId = dragSessionId || draggingId.value;
|
||||
const columnId = draggingColumnId.value;
|
||||
clearDragState();
|
||||
|
||||
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت بهاشتباه reorder ستون نشود
|
||||
const activityId =
|
||||
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
|
||||
if (activityId) {
|
||||
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
|
||||
void enqueueMove(() => moveCard(activityId, stageId, index));
|
||||
return;
|
||||
}
|
||||
if (!raw || moving.value) return;
|
||||
await moveCard(raw, stageId, index);
|
||||
|
||||
const isStageDrag = transfer.startsWith('stage:');
|
||||
if (isStageDrag || columnId) {
|
||||
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
|
||||
if (fromId) await reorderColumn(fromId, stageId);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOrder(ids: string[]) {
|
||||
@@ -389,7 +502,7 @@ async function onAddStage(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
function activityQuery(id: string) {
|
||||
const query: Record<string, string> = {
|
||||
from: 'desk',
|
||||
nav: crypto.randomUUID(),
|
||||
@@ -409,7 +522,31 @@ function openActivity(id: string) {
|
||||
if (source.due) query.due = source.due;
|
||||
const columnId = findStageOf(id);
|
||||
if (columnId) query.stage = columnId;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query });
|
||||
return query;
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
if (quickId.value === id) {
|
||||
closeQuick();
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(peekLinger);
|
||||
quickId.value = id;
|
||||
peekId.value = id;
|
||||
}
|
||||
|
||||
function closeQuick() {
|
||||
quickId.value = null;
|
||||
const id = peekId.value;
|
||||
window.clearTimeout(peekLinger);
|
||||
peekLinger = window.setTimeout(() => {
|
||||
if (!quickId.value && peekId.value === id) peekId.value = null;
|
||||
}, 650);
|
||||
}
|
||||
|
||||
function openFullActivity(id: string) {
|
||||
quickId.value = null;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -425,23 +562,32 @@ function openActivity(id: string) {
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
:preview-id="quickId"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@detail-deal="openFullActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@column-drag-start="onColumnDragStart"
|
||||
@column-drag-end="onColumnDragEnd"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
<ActivityQuickView
|
||||
:activity-id="quickId"
|
||||
@close="closeQuick"
|
||||
@changed="loadAll()"
|
||||
@full="openFullActivity"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
|
||||
@@ -17,6 +17,8 @@ const props = defineProps<{
|
||||
activity: ActivityDto;
|
||||
saving?: boolean;
|
||||
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<div class="backdrop" aria-hidden="true" />
|
||||
<section
|
||||
class="sheet"
|
||||
|
||||
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
|
||||
const props = defineProps<{
|
||||
dueAt?: string | null;
|
||||
saving?: boolean;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
||||
<div class="handle" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Image as AImage,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { ActivityDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
|
||||
import { noteChips } from '@/lib/lead-form-notes';
|
||||
import {
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
formatJalaliDayClock,
|
||||
formatJalaliNumeric,
|
||||
formatLocalDateTimeInput,
|
||||
parseLocalInput,
|
||||
startOfLocalDay,
|
||||
} from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canMutateActivity } from '@/lib/activity-access';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
activityId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
changed: [];
|
||||
full: [id: string];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const rendered = ref(false);
|
||||
const placed = ref(false);
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const frame = ref({ left: 12, top: 12, width: 440, maxHeight: 560, shift: 88 });
|
||||
let hideTimer = 0;
|
||||
let reveal = false;
|
||||
const VIEW_PAD = 16;
|
||||
|
||||
function cardEl(id: string) {
|
||||
return document.querySelector<HTMLElement>(`.ant-card[data-card-id="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
function columnScroller(el: HTMLElement) {
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
|
||||
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function panelExtent() {
|
||||
const content = panelEl.value?.scrollHeight ?? 0;
|
||||
return content > 80 ? content : 328;
|
||||
}
|
||||
|
||||
/** Lift the column so a top-aligned panel fits. The last card may have no room left. */
|
||||
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
|
||||
const overflow = cardTop + height - (vh - VIEW_PAD);
|
||||
if (overflow <= 8) return;
|
||||
const scroller = columnScroller(el);
|
||||
if (!scroller) return;
|
||||
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
||||
const delta = Math.min(overflow, Math.max(0, room));
|
||||
if (delta <= 8) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||
scroller.scrollBy({ top: delta, behavior: motion });
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const id = props.activityId;
|
||||
if (!id) return;
|
||||
const el = cardEl(id);
|
||||
if (!el) return;
|
||||
let rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8) return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const width = Math.min(440, vw - 24);
|
||||
const gap = 10;
|
||||
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
|
||||
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
|
||||
left = Math.max(12, Math.min(left, vw - width - 12));
|
||||
const height = Math.min(panelExtent(), Math.max(160, vh - VIEW_PAD * 2));
|
||||
if (reveal) {
|
||||
reveal = false;
|
||||
liftColumn(el, rect.top, height, vh);
|
||||
rect = el.getBoundingClientRect();
|
||||
}
|
||||
let top = rect.top;
|
||||
const maxTop = vh - VIEW_PAD - height;
|
||||
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
|
||||
if (top < VIEW_PAD) top = VIEW_PAD;
|
||||
const maxHeight = Math.max(160, vh - VIEW_PAD - top);
|
||||
const travel = Math.min(120, Math.round(width * 0.28));
|
||||
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
|
||||
}
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
position: 'fixed' as const,
|
||||
zIndex: 1050,
|
||||
top: `${frame.value.top}px`,
|
||||
left: `${frame.value.left}px`,
|
||||
width: `${frame.value.width}px`,
|
||||
maxHeight: `${frame.value.maxHeight}px`,
|
||||
overflow: 'auto' as const,
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: `${token.value.padding}px`,
|
||||
background: token.value.colorBgElevated,
|
||||
color: token.value.colorText,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadowSecondary,
|
||||
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
|
||||
opacity: placed.value ? 1 : 0,
|
||||
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
|
||||
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
function onViewport() {
|
||||
if (props.activityId) measure();
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!props.activityId || !placed.value) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (panelEl.value?.contains(target)) return;
|
||||
if (commentOpen.value) return;
|
||||
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
|
||||
const card = cardEl(props.activityId);
|
||||
if (card?.contains(target)) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || !props.activityId) return;
|
||||
if (commentOpen.value) return;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onViewport);
|
||||
window.addEventListener('scroll', onViewport, true);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onViewport);
|
||||
window.removeEventListener('scroll', onViewport, true);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.clearTimeout(hideTimer);
|
||||
clearNoteMedia();
|
||||
});
|
||||
|
||||
const activity = ref<ActivityDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const commentOpen = ref(false);
|
||||
const postponeKey = ref('h1');
|
||||
const customDue = ref('');
|
||||
|
||||
type NoteMedia = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: 'image' | 'audio' | 'file';
|
||||
createdAt: string;
|
||||
};
|
||||
const noteMedia = ref<NoteMedia[]>([]);
|
||||
|
||||
function clearNoteMedia() {
|
||||
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
|
||||
noteMedia.value = [];
|
||||
}
|
||||
|
||||
const isClosed = computed(
|
||||
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
||||
);
|
||||
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
|
||||
const needsEveryone = computed(
|
||||
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
|
||||
);
|
||||
const myShareDone = computed(() => {
|
||||
const mine = auth.user?.id;
|
||||
return Boolean(mine && activity.value?.completedByIds?.includes(mine));
|
||||
});
|
||||
const actionLabel = computed(() => {
|
||||
if (isClosed.value) return 'بازگشایی';
|
||||
if (needsEveryone.value && myShareDone.value) return 'لغو انجام';
|
||||
return 'انجام شده';
|
||||
});
|
||||
|
||||
const createdLabel = computed(() => {
|
||||
const raw = activity.value?.createdAt;
|
||||
if (!raw) return '—';
|
||||
const date = new Date(raw);
|
||||
return `${formatJalaliDay(date)} - ${formatClockFa(date)}`;
|
||||
});
|
||||
|
||||
const thread = computed(() => parseActivityThread(activity.value?.description));
|
||||
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
|
||||
const threadBlocks = computed(() => {
|
||||
const entries = thread.value;
|
||||
if (!entries) return null;
|
||||
return bindThreadMedia(entries, noteMedia.value);
|
||||
});
|
||||
const looseImages = computed(() =>
|
||||
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
|
||||
);
|
||||
|
||||
const noteScrollStyle = computed(() => ({
|
||||
maxHeight: '220px',
|
||||
overflow: 'auto',
|
||||
}));
|
||||
|
||||
const noteBubbleStyle = computed(() => ({
|
||||
padding: '8px 12px',
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: token.value.colorFillAlter,
|
||||
}));
|
||||
|
||||
const postponeBase = computed(() => {
|
||||
const now = new Date();
|
||||
const due = activity.value?.dueAt ? new Date(activity.value.dueAt) : now;
|
||||
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
|
||||
return due;
|
||||
});
|
||||
|
||||
function addMinutes(mins: number) {
|
||||
return new Date(postponeBase.value.getTime() + mins * 60_000);
|
||||
}
|
||||
|
||||
function tomorrowMorning() {
|
||||
const d = startOfLocalDay(new Date());
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(10, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function nextSaturdayMorning() {
|
||||
const d = startOfLocalDay(new Date());
|
||||
const untilSaturday = (6 - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
|
||||
d.setHours(10, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
const postponeChoices = computed(() => {
|
||||
const h1 = addMinutes(60);
|
||||
const morning = tomorrowMorning();
|
||||
const saturday = nextSaturdayMorning();
|
||||
const week = new Date(postponeBase.value.getTime());
|
||||
week.setDate(week.getDate() + 7);
|
||||
return [
|
||||
{ key: 'h1', label: `یک ساعت دیرتر · ${formatClockFa(h1)}`, at: h1 },
|
||||
{ key: 'tomorrow-am', label: `فردا صبح · ${formatClockFa(morning)}`, at: morning },
|
||||
{ key: 'next-saturday', label: `شنبه آینده · ${formatJalaliDayClock(saturday)}`, at: saturday },
|
||||
{ key: 'next-week', label: `هفته آینده · ${formatJalaliDayClock(week)}`, at: week },
|
||||
{ key: 'custom', label: 'زمان دلخواه', at: null },
|
||||
];
|
||||
});
|
||||
|
||||
const postponeReady = computed(() => {
|
||||
if (postponeKey.value === 'custom') return Boolean(parseLocalInput(customDue.value));
|
||||
return postponeChoices.value.some((item) => item.key === postponeKey.value && item.at);
|
||||
});
|
||||
|
||||
function resetDrafts() {
|
||||
commentOpen.value = false;
|
||||
postponeKey.value = 'h1';
|
||||
customDue.value = '';
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
if (Array.isArray(msg)) return msg[0] ?? fallback;
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadNoteMedia(row: ActivityDto) {
|
||||
clearNoteMedia();
|
||||
const next: NoteMedia[] = [];
|
||||
for (const image of row.images ?? []) {
|
||||
const mime = image.mimeType.toLowerCase();
|
||||
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
||||
try {
|
||||
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(data);
|
||||
if (props.activityId !== row.id) {
|
||||
URL.revokeObjectURL(url);
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: image.id,
|
||||
name: image.fileName,
|
||||
url,
|
||||
kind,
|
||||
createdAt: image.createdAt,
|
||||
});
|
||||
} catch {
|
||||
/* فایل در دسترس نیست */
|
||||
}
|
||||
}
|
||||
if (props.activityId !== row.id) {
|
||||
for (const item of next) URL.revokeObjectURL(item.url);
|
||||
return;
|
||||
}
|
||||
noteMedia.value = next;
|
||||
await nextTick();
|
||||
measure();
|
||||
}
|
||||
|
||||
async function load(id: string) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id}`);
|
||||
if (props.activityId !== id) return;
|
||||
activity.value = data;
|
||||
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
|
||||
void loadNoteMedia(data);
|
||||
} catch (e) {
|
||||
if (props.activityId === id) {
|
||||
activity.value = null;
|
||||
error.value = apiMessage(e, 'بارگذاری فعالیت ناموفق بود.');
|
||||
}
|
||||
} finally {
|
||||
if (props.activityId === id) {
|
||||
loading.value = false;
|
||||
reveal = true;
|
||||
await nextTick();
|
||||
measure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activityId,
|
||||
async (id, prev) => {
|
||||
if (!id) {
|
||||
activity.value = null;
|
||||
error.value = '';
|
||||
resetDrafts();
|
||||
clearNoteMedia();
|
||||
placed.value = false;
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
if (!props.activityId) rendered.value = false;
|
||||
}, 230);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(hideTimer);
|
||||
if (prev && prev !== id) {
|
||||
resetDrafts();
|
||||
activity.value = null;
|
||||
clearNoteMedia();
|
||||
}
|
||||
if (prev && prev !== id && rendered.value) placed.value = false;
|
||||
rendered.value = true;
|
||||
measure();
|
||||
await nextTick();
|
||||
measure();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (props.activityId === id) placed.value = true;
|
||||
});
|
||||
});
|
||||
void load(id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function runAction() {
|
||||
const id = props.activityId;
|
||||
const row = activity.value;
|
||||
if (!id || !row || saving.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (isClosed.value) {
|
||||
if (!canMutate.value) {
|
||||
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
|
||||
return;
|
||||
}
|
||||
await api.patch(`/activities/${id}`, { status: 'open' });
|
||||
await load(id);
|
||||
emit('changed');
|
||||
return;
|
||||
}
|
||||
const undo = needsEveryone.value && myShareDone.value;
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${id}/complete`, undefined, {
|
||||
params: undo ? { undo: '1' } : undefined,
|
||||
});
|
||||
if (data.status === 'done') {
|
||||
emit('changed');
|
||||
emit('close');
|
||||
return;
|
||||
}
|
||||
activity.value = data;
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ثبت تغییر ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postponeTo(iso: string) {
|
||||
const id = props.activityId;
|
||||
if (!id || saving.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id}`, { dueAt: iso });
|
||||
await load(id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'تعویق فعالیت ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
||||
const id = props.activityId;
|
||||
const row = activity.value;
|
||||
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
|
||||
if (!id || !row || !body) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const prev = row.description?.trim();
|
||||
const now = new Date();
|
||||
const author = auth.user?.fullName?.trim();
|
||||
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
|
||||
const entry = `${stamp}\n${body}`;
|
||||
await api.patch(`/activities/${id}`, { description: prev ? `${prev}\n\n${entry}` : entry });
|
||||
commentOpen.value = false;
|
||||
await load(id);
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ثبت نظر ناموفق بود.');
|
||||
throw e;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPostpone() {
|
||||
if (postponeKey.value === 'custom') {
|
||||
const at = parseLocalInput(customDue.value);
|
||||
if (!at) return;
|
||||
await postponeTo(at.toISOString());
|
||||
return;
|
||||
}
|
||||
const at = postponeChoices.value.find((item) => item.key === postponeKey.value)?.at;
|
||||
if (at) await postponeTo(at.toISOString());
|
||||
}
|
||||
|
||||
function openFull() {
|
||||
if (!props.activityId) return;
|
||||
emit('full', props.activityId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rendered"
|
||||
ref="panelEl"
|
||||
role="dialog"
|
||||
dir="rtl"
|
||||
:aria-label="activity?.title || 'جزئیات فعالیت'"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
|
||||
<a-button type="text" aria-label="بستن" @click="emit('close')">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-alert v-if="error" type="error" :message="error" show-icon :style="{ marginBottom: '12px' }" />
|
||||
<template v-if="activity">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-text type="secondary">ایجاد شده توسط</a-typography-text>
|
||||
<a-typography-text>{{ activity.createdBy?.fullName || '—' }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-text type="secondary">تاریخ ایجاد</a-typography-text>
|
||||
<a-typography-text>{{ createdLabel }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
|
||||
<a-typography-text strong>یادداشت</a-typography-text>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
aria-label="یادداشت جدید"
|
||||
:disabled="!canMutate"
|
||||
@click="commentOpen = true"
|
||||
>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<div class="note-photos" :style="noteScrollStyle">
|
||||
<a-flex v-if="threadBlocks" vertical :gap="8">
|
||||
<div
|
||||
v-for="(entry, index) in threadBlocks.blocks"
|
||||
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
|
||||
:style="noteBubbleStyle"
|
||||
>
|
||||
<a-flex v-if="entry.dateLabel" justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="entry.author" strong :style="{ color: token.colorPrimary }">
|
||||
{{ entry.author }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
|
||||
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex
|
||||
v-if="entry.media.some((item) => item.kind === 'image')"
|
||||
wrap="wrap"
|
||||
:gap="8"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>
|
||||
<a-image
|
||||
v-for="image in entry.media.filter((item) => item.kind === 'image')"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
|
||||
<a-image :src="image.url" :alt="image.name" :width="72"
|
||||
:height="72" :preview="{ zIndex: 1300 }" />
|
||||
</div>
|
||||
</a-flex>
|
||||
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
|
||||
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
||||
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
|
||||
<a-image
|
||||
v-for="image in looseImages"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
|
||||
</div>
|
||||
|
||||
<a-flex vertical :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-flex align="center" :gap="8">
|
||||
<a-select
|
||||
v-model:value="postponeKey"
|
||||
:disabled="saving || loading || isClosed || !canMutate"
|
||||
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
|
||||
:dropdown-style="{ zIndex: 1200 }"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || isClosed || !canMutate || !postponeReady"
|
||||
@click="applyPostpone"
|
||||
>
|
||||
تعویق
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<JalaliDateInput
|
||||
v-if="postponeKey === 'custom'"
|
||||
v-model="customDue"
|
||||
mode="datetime"
|
||||
placeholder="تاریخ و ساعت"
|
||||
:disabled="!canMutate"
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<a-divider :style="{ margin: '16px 0 12px' }" />
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || loading || (isClosed && !canMutate)"
|
||||
@click="runAction"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</a-button>
|
||||
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-spin>
|
||||
|
||||
<ActivityCommentSheet
|
||||
v-if="commentOpen && activity"
|
||||
:activity="activity"
|
||||
:saving="saving"
|
||||
:overlay-z-index="1200"
|
||||
:save="submitComment"
|
||||
@close="commentOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-photos :deep(.ant-image) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.note-photos :deep(.ant-image-img) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Collapse as ACollapse,
|
||||
CollapsePanel as ACollapsePanel,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
ScheduleOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
startOfLocalDay,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
|
||||
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const STATUS_LABELS: Record<ActivityStatus, string> = {
|
||||
open: 'باز',
|
||||
done: 'انجامشده',
|
||||
canceled: 'لغو شده',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
|
||||
call: PhoneOutlined,
|
||||
meeting: TeamOutlined,
|
||||
task: CheckCircleOutlined,
|
||||
note: ScheduleOutlined,
|
||||
};
|
||||
|
||||
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
dealId: string;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealTitle?: string;
|
||||
}>(),
|
||||
{
|
||||
personId: null,
|
||||
companyId: null,
|
||||
dealTitle: '',
|
||||
},
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const items = ref<ActivityDto[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const closedKeys = ref<string[]>([]);
|
||||
|
||||
function sortByDue(a: ActivityDto, b: ActivityDto) {
|
||||
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
|
||||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||
if (Number.isNaN(ta)) return 1;
|
||||
if (Number.isNaN(tb)) return -1;
|
||||
return ta - tb;
|
||||
}
|
||||
|
||||
const planned = computed(() =>
|
||||
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
|
||||
);
|
||||
const closed = computed(() =>
|
||||
items.value
|
||||
.filter((a) => a.status !== 'open')
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
|
||||
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
|
||||
return tb.localeCompare(ta);
|
||||
}),
|
||||
);
|
||||
|
||||
function dueTone(dueAt: string | null): DueTone {
|
||||
if (!dueAt) return 'none';
|
||||
const due = new Date(dueAt);
|
||||
if (Number.isNaN(due.getTime())) return 'none';
|
||||
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
|
||||
if (days < 0) return 'overdue';
|
||||
if (days === 0) return 'today';
|
||||
if (days <= 2) return 'soon';
|
||||
return 'later';
|
||||
}
|
||||
|
||||
function dueLabel(dueAt: string | null) {
|
||||
if (!dueAt) return 'بدون موعد';
|
||||
const d = new Date(dueAt);
|
||||
if (Number.isNaN(d.getTime())) return 'بدون موعد';
|
||||
const tone = dueTone(dueAt);
|
||||
if (tone === 'overdue') {
|
||||
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
|
||||
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
|
||||
}
|
||||
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
|
||||
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
function dueTagColor(tone: DueTone) {
|
||||
if (tone === 'overdue') return 'error';
|
||||
if (tone === 'today') return 'warning';
|
||||
if (tone === 'soon') return 'processing';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function rowStyle(tone: DueTone, closedRow = false) {
|
||||
const base = {
|
||||
width: '100%',
|
||||
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
background: token.value.colorBgContainer,
|
||||
cursor: 'pointer' as const,
|
||||
opacity: closedRow ? 0.78 : 1,
|
||||
};
|
||||
if (closedRow) {
|
||||
return { ...base, background: token.value.colorFillAlter };
|
||||
}
|
||||
if (tone === 'overdue') {
|
||||
return {
|
||||
...base,
|
||||
borderColor: token.value.colorErrorBorder,
|
||||
background: token.value.colorErrorBg,
|
||||
};
|
||||
}
|
||||
if (tone === 'today') {
|
||||
return {
|
||||
...base,
|
||||
borderColor: token.value.colorWarningBorder,
|
||||
background: token.value.colorWarningBg,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function avatarStyle(tone: DueTone) {
|
||||
if (tone === 'overdue') {
|
||||
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
|
||||
}
|
||||
if (tone === 'today') {
|
||||
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
|
||||
}
|
||||
return {
|
||||
backgroundColor: token.value.colorPrimaryBg,
|
||||
color: token.value.colorPrimary,
|
||||
};
|
||||
}
|
||||
|
||||
function typeLabel(item: ActivityDto) {
|
||||
const type = item.type as ActivityType;
|
||||
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
|
||||
}
|
||||
|
||||
function statusLabel(status: ActivityDto['status']) {
|
||||
return STATUS_LABELS[status as ActivityStatus];
|
||||
}
|
||||
|
||||
function typeIcon(type: ActivityDto['type']) {
|
||||
return TYPE_ICONS[type as ActivityType];
|
||||
}
|
||||
|
||||
function newActivityQuery() {
|
||||
const query: Record<string, string> = { dealId: props.dealId };
|
||||
if (props.personId) query.personId = props.personId;
|
||||
if (props.companyId) query.companyId = props.companyId;
|
||||
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
|
||||
return query;
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
void router.push({ name: 'activity-new', query: newActivityQuery() });
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
void router.push({ name: 'activity-detail', params: { id } });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.dealId) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
||||
params: { filter: 'all', dealId: props.dealId, limit: 50 },
|
||||
});
|
||||
items.value = data.items ?? [];
|
||||
} catch {
|
||||
error.value = 'بارگذاری فعالیتهای معامله ممکن نشد.';
|
||||
items.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.dealId,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<a-space :size="8">
|
||||
<CalendarOutlined :style="{ color: token.colorPrimary }" />
|
||||
<span>فعالیتهای برنامهریزیشده</span>
|
||||
<a-tag v-if="planned.length" color="processing" :bordered="false">
|
||||
{{ toFaDigits(planned.length) }} باز
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="goCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن فعالیت
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginBottom: `${token.margin}px` }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !planned.length"
|
||||
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
|
||||
:style="{ paddingBlock: `${token.paddingLG}px` }"
|
||||
>
|
||||
<template #description>
|
||||
<a-flex vertical align="center" :gap="token.marginXS">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
هنوز فعالیتی برای این معامله نیست
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph
|
||||
type="secondary"
|
||||
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
|
||||
>
|
||||
یک تماس، جلسه یا پیگیری زمانبندی کن تا معامله از دست نرود.
|
||||
</a-typography-paragraph>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-button type="primary" @click="goCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن اولین فعالیت
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
|
||||
<a-flex
|
||||
v-for="item in planned"
|
||||
:key="item.id"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
wrap="wrap"
|
||||
:style="rowStyle(dueTone(item.dueAt))"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goDetail(item.id)"
|
||||
@keydown.enter="goDetail(item.id)"
|
||||
>
|
||||
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
|
||||
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
|
||||
<template #icon>
|
||||
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
|
||||
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
|
||||
<component :is="typeIcon(item.type)" v-else />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||||
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
|
||||
{{ item.title }}
|
||||
</a-typography-text>
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
|
||||
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
|
||||
{{ dueLabel(item.dueAt) }}
|
||||
</a-tag>
|
||||
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
|
||||
{{ item.assignee.fullName }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<template v-if="closed.length">
|
||||
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
|
||||
<a-collapse
|
||||
v-model:activeKey="closedKeys"
|
||||
ghost
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: 'transparent',
|
||||
marginTop: planned.length ? 0 : `${token.marginSM}px`,
|
||||
}"
|
||||
>
|
||||
<a-collapse-panel key="closed">
|
||||
<template #header>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(closed.length) }} فعالیت انجامشده یا لغو شده
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<a-flex vertical :gap="token.marginSM">
|
||||
<a-flex
|
||||
v-for="item in closed"
|
||||
:key="item.id"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
wrap="wrap"
|
||||
:style="rowStyle('none', true)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goDetail(item.id)"
|
||||
@keydown.enter="goDetail(item.id)"
|
||||
>
|
||||
<a-flex
|
||||
align="center"
|
||||
:gap="token.marginSM"
|
||||
:style="{ minWidth: 0, flex: '1 1 220px' }"
|
||||
>
|
||||
<a-avatar
|
||||
:size="40"
|
||||
:style="{
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="typeIcon(item.type)" />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
|
||||
<a-typography-text :ellipsis="{ tooltip: item.title }">
|
||||
{{ item.title }}
|
||||
</a-typography-text>
|
||||
<a-space :size="8" wrap>
|
||||
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
|
||||
<a-typography-text type="secondary">
|
||||
{{ dueLabel(item.dueAt) }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
FlagFilled,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
@@ -13,10 +14,13 @@ import {
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
|
||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||
import type { ActivityPriority } from '@mento/shared';
|
||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
||||
|
||||
@@ -28,6 +32,9 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
peeking?: boolean;
|
||||
/** پنل کناری همین کارت باز است؛ فلش را برمیگرداند. جدا از هایلایتِ بعد از بستن. */
|
||||
previewing?: boolean;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY },
|
||||
);
|
||||
@@ -36,6 +43,7 @@ const emit = defineEmits<{
|
||||
dragStart: [deal: PipelineDeal, event: DragEvent];
|
||||
dragEnd: [];
|
||||
open: [dealId: string];
|
||||
detail: [dealId: string];
|
||||
menu: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -44,6 +52,11 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const activityOpen = ref(false);
|
||||
const highlighted = computed(
|
||||
() => props.selected || props.peeking || activityOpen.value,
|
||||
);
|
||||
|
||||
const showAmount = computed(
|
||||
() => !props.copy.hideAmount && props.deal.amount != null,
|
||||
);
|
||||
@@ -60,14 +73,51 @@ const extraAssignees = computed(() =>
|
||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||
);
|
||||
const showTrash = computed(() => props.selectMode && props.selected);
|
||||
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
||||
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
||||
high: { label: 'زیاد', color: '#ea580c' },
|
||||
medium: { label: 'متوسط', color: '#eab308' },
|
||||
low: { label: 'کم', color: '#16a34a' },
|
||||
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
||||
};
|
||||
const priorityFlag = computed(() =>
|
||||
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
||||
);
|
||||
|
||||
function onTitleClick() {
|
||||
if (props.selectMode) {
|
||||
emit('toggleSelect', props.deal.id);
|
||||
return;
|
||||
}
|
||||
if (props.copy.quickView) emit('detail', props.deal.id);
|
||||
else emit('open', props.deal.id);
|
||||
}
|
||||
|
||||
const chevronStyle = computed(() => ({
|
||||
display: 'inline-block',
|
||||
width: '0.42rem',
|
||||
height: '0.42rem',
|
||||
borderRight: '1.6px solid currentColor',
|
||||
borderBottom: '1.6px solid currentColor',
|
||||
transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)',
|
||||
transition: 'transform 0.15s ease',
|
||||
}));
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||
opacity: props.dragging ? 0.45 : 1,
|
||||
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو میکند؛ فقط سایه
|
||||
opacity: props.dragging ? 0.55 : 1,
|
||||
transition: 'opacity 120ms ease, box-shadow 120ms ease',
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: token.value.boxShadowTertiary,
|
||||
position: highlighted.value ? 'relative' : undefined,
|
||||
zIndex: highlighted.value ? 2 : undefined,
|
||||
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: highlighted.value
|
||||
? `0 0 0 2px ${token.value.colorPrimary}`
|
||||
: props.dragging
|
||||
? token.value.boxShadow
|
||||
: token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -109,21 +159,29 @@ const cardStyle = computed(() => ({
|
||||
</template>
|
||||
</a-button>
|
||||
<div :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
|
||||
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
|
||||
</a-tooltip>
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="onTitleClick"
|
||||
@dblclick.stop
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-text
|
||||
v-if="deal.description"
|
||||
type="secondary"
|
||||
@@ -183,17 +241,44 @@ const cardStyle = computed(() => ({
|
||||
{{ deal.date }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text
|
||||
v-if="showAmount"
|
||||
strong
|
||||
dir="ltr"
|
||||
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
|
||||
>
|
||||
{{ money.number }}
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ money.unit }}
|
||||
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
|
||||
<a-typography-text
|
||||
v-if="showAmount"
|
||||
strong
|
||||
dir="ltr"
|
||||
:style="{ margin: 0, whiteSpace: 'nowrap' }"
|
||||
>
|
||||
{{ money.number }}
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
<a-button
|
||||
v-if="copy.quickView && !selectMode"
|
||||
type="text"
|
||||
:aria-label="previewing ? 'بستن پیشنمایش' : 'پیشنمایش فعالیت'"
|
||||
:aria-expanded="previewing"
|
||||
:style="{ color: token.colorTextSecondary }"
|
||||
@click.stop="emit('open', deal.id)"
|
||||
>
|
||||
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
|
||||
</a-button>
|
||||
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span
|
||||
v-if="copy.quickActivity && !selectMode"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<DealQuickActivityPopover
|
||||
:deal="deal"
|
||||
@update:open="activityOpen = $event"
|
||||
/>
|
||||
</span>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
message,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { CalendarOutlined, CloseOutlined, FormOutlined } from '@ant-design/icons-vue';
|
||||
import type { ActivityDto, ActivityType, CreateActivityRequest } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import type { PipelineDeal } from '@/lib/pipeline-board';
|
||||
|
||||
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
deal: PipelineDeal;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const open = ref(false);
|
||||
const rendered = ref(false);
|
||||
const placed = ref(false);
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
|
||||
let hideTimer = 0;
|
||||
let reveal = false;
|
||||
const VIEW_PAD = 16;
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const title = ref('');
|
||||
const type = ref<ActivityType>('task');
|
||||
const dueDate = ref('');
|
||||
const dueTime = ref('');
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
|
||||
.filter(([value]) => value !== 'note')
|
||||
.map(([value, label]) => ({ value, label })),
|
||||
);
|
||||
|
||||
const fieldItemStyle = computed(() => ({
|
||||
marginBottom: 0,
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
function resetForm() {
|
||||
title.value = `پیگیری: ${props.deal.title}`;
|
||||
type.value = 'task';
|
||||
dueDate.value = formatLocalDateInput(new Date());
|
||||
dueTime.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function cardEl() {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function columnScroller(el: HTMLElement) {
|
||||
let node = el.parentElement;
|
||||
while (node) {
|
||||
const style = getComputedStyle(node);
|
||||
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
|
||||
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function panelExtent() {
|
||||
const content = panelEl.value?.scrollHeight ?? 0;
|
||||
return content > 80 ? content : 360;
|
||||
}
|
||||
|
||||
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
|
||||
const overflow = cardTop + height - (vh - VIEW_PAD);
|
||||
if (overflow <= 8) return;
|
||||
const scroller = columnScroller(el);
|
||||
if (!scroller) return;
|
||||
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
||||
const delta = Math.min(overflow, Math.max(0, room));
|
||||
if (delta <= 8) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
||||
scroller.scrollBy({ top: delta, behavior: motion });
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const el = cardEl();
|
||||
if (!el) return;
|
||||
let rect = el.getBoundingClientRect();
|
||||
if (rect.width < 8) return;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const width = Math.min(360, vw - 24);
|
||||
const gap = 10;
|
||||
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
|
||||
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
|
||||
left = Math.max(12, Math.min(left, vw - width - 12));
|
||||
const height = Math.min(panelExtent(), Math.max(200, vh - VIEW_PAD * 2));
|
||||
if (reveal) {
|
||||
reveal = false;
|
||||
liftColumn(el, rect.top, height, vh);
|
||||
rect = el.getBoundingClientRect();
|
||||
}
|
||||
let top = rect.top;
|
||||
const maxTop = vh - VIEW_PAD - height;
|
||||
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
|
||||
if (top < VIEW_PAD) top = VIEW_PAD;
|
||||
const maxHeight = Math.max(200, vh - VIEW_PAD - top);
|
||||
const travel = Math.min(100, Math.round(width * 0.28));
|
||||
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
|
||||
}
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
position: 'fixed' as const,
|
||||
zIndex: 1050,
|
||||
top: `${frame.value.top}px`,
|
||||
left: `${frame.value.left}px`,
|
||||
width: `${frame.value.width}px`,
|
||||
maxHeight: `${frame.value.maxHeight}px`,
|
||||
overflow: 'auto' as const,
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: `${token.value.padding}px`,
|
||||
background: token.value.colorBgElevated,
|
||||
color: token.value.colorText,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: token.value.boxShadowSecondary,
|
||||
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
|
||||
opacity: placed.value ? 1 : 0,
|
||||
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
|
||||
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
if (open.value === next) return;
|
||||
open.value = next;
|
||||
emit('update:open', next);
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (props.disabled) return;
|
||||
setOpen(!open.value);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function onViewport() {
|
||||
if (open.value) measure();
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!open.value || !placed.value) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (panelEl.value?.contains(target)) return;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('.ant-select-dropdown, #jalali-picker, .layer')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const card = cardEl();
|
||||
if (card?.contains(target)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open.value) close();
|
||||
}
|
||||
|
||||
watch(open, async (isOpen) => {
|
||||
if (!isOpen) {
|
||||
placed.value = false;
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
if (!open.value) rendered.value = false;
|
||||
}, 230);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(hideTimer);
|
||||
resetForm();
|
||||
rendered.value = true;
|
||||
reveal = true;
|
||||
measure();
|
||||
await nextTick();
|
||||
measure();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (open.value) {
|
||||
placed.value = true;
|
||||
void nextTick().then(() => measure());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onViewport);
|
||||
window.addEventListener('scroll', onViewport, true);
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onViewport);
|
||||
window.removeEventListener('scroll', onViewport, true);
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.clearTimeout(hideTimer);
|
||||
});
|
||||
|
||||
function dueAtIso(): string | null {
|
||||
if (!dueDate.value) return null;
|
||||
const time = dueTime.value.trim() || '10:30';
|
||||
const due = new Date(`${dueDate.value}T${time}:00`);
|
||||
return Number.isNaN(due.getTime()) ? null : due.toISOString();
|
||||
}
|
||||
|
||||
function fullFormQuery() {
|
||||
const query: Record<string, string> = { dealId: props.deal.id };
|
||||
if (props.deal.personId) query.personId = props.deal.personId;
|
||||
if (props.deal.companyId) query.companyId = props.deal.companyId;
|
||||
if (title.value.trim()) query.title = title.value.trim();
|
||||
if (type.value) query.type = type.value;
|
||||
if (dueAtIso()) query.dueAt = dueAtIso()!;
|
||||
return query;
|
||||
}
|
||||
|
||||
function goFullForm() {
|
||||
close();
|
||||
void router.push({ name: 'activity-new', query: fullFormQuery() });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
if (title.value.trim().length < 2) {
|
||||
error.value = 'عنوان فعالیت را بنویسید.';
|
||||
return;
|
||||
}
|
||||
const dueAt = dueAtIso();
|
||||
if (!dueDate.value || !dueAt) {
|
||||
error.value = 'تاریخ فعالیت را مشخص کنید.';
|
||||
return;
|
||||
}
|
||||
const body: CreateActivityRequest = {
|
||||
title: title.value.trim(),
|
||||
type: type.value,
|
||||
dueAt,
|
||||
dealId: props.deal.id,
|
||||
personId: props.deal.personId || null,
|
||||
companyId: props.deal.companyId || null,
|
||||
assigneeId: props.deal.ownerId || null,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post<ActivityDto>('/activities', body);
|
||||
message.success('فعالیت ثبت شد');
|
||||
close();
|
||||
} catch {
|
||||
error.value = 'ثبت فعالیت ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tooltip title="افزودن فعالیت">
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
aria-label="افزودن فعالیت سریع"
|
||||
:aria-expanded="open"
|
||||
:style="{
|
||||
color: open ? token.colorPrimary : token.colorTextSecondary,
|
||||
width: '28px',
|
||||
minWidth: '28px',
|
||||
height: '28px',
|
||||
padding: 0,
|
||||
}"
|
||||
@click.stop="toggleOpen"
|
||||
@mousedown.stop
|
||||
@dragstart.stop.prevent
|
||||
>
|
||||
<template #icon><CalendarOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rendered"
|
||||
ref="panelEl"
|
||||
role="dialog"
|
||||
dir="rtl"
|
||||
:aria-label="`ایجاد فعالیت سریع برای ${deal.title}`"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">ایجاد فعالیت سریع</a-typography-title>
|
||||
<a-button type="text" aria-label="بستن" @click="close">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-flex vertical :gap="token.marginSM">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-form-item label="عنوان" :style="fieldItemStyle">
|
||||
<a-input v-model:value="title" placeholder="عنوان فعالیت" allow-clear />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نوع" :style="fieldItemStyle">
|
||||
<a-select v-model:value="type" :options="typeOptions" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 12]" align="top">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="تاریخ" :style="fieldItemStyle">
|
||||
<JalaliDateInput v-model="dueDate" placeholder="تاریخ" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="ساعت" :style="fieldItemStyle">
|
||||
<JalaliDateInput
|
||||
v-model="dueTime"
|
||||
mode="time"
|
||||
placeholder="اختیاری"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
|
||||
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-link @click="goFullForm">
|
||||
<FormOutlined />
|
||||
فرم کامل
|
||||
</a-typography-link>
|
||||
<a-space :size="8">
|
||||
<a-button :disabled="saving" @click="close">انصراف</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -21,8 +21,10 @@ withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
previewId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -32,6 +34,7 @@ const emit = defineEmits<{
|
||||
drop: [stageId: string, event: DragEvent, index?: number];
|
||||
addDeal: [stageId: string];
|
||||
openDeal: [dealId: string];
|
||||
detailDeal: [dealId: string];
|
||||
menuDeal: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -109,6 +112,8 @@ function submitAdd() {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
:preview-id="previewId"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === stages.length - 1"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@@ -117,6 +122,7 @@ function submitAdd() {
|
||||
@drop="(id, e, index) => emit('drop', id, e, index)"
|
||||
@add-deal="emit('addDeal', $event)"
|
||||
@open-deal="emit('openDeal', $event)"
|
||||
@detail-deal="emit('detailDeal', $event)"
|
||||
@menu-deal="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import AddDealButton from './AddDealButton.vue';
|
||||
import DealCard from './DealCard.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -26,8 +26,10 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
previewId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,6 +39,7 @@ const emit = defineEmits<{
|
||||
drop: [stageId: string, event: DragEvent, index?: number];
|
||||
addDeal: [stageId: string];
|
||||
openDeal: [dealId: string];
|
||||
detailDeal: [dealId: string];
|
||||
menuDeal: [dealId: string];
|
||||
toggleSelect: [dealId: string];
|
||||
bulkDelete: [];
|
||||
@@ -51,6 +54,8 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const bodyEl = ref<HTMLElement | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const renaming = ref(false);
|
||||
const draft = ref('');
|
||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||
@@ -106,16 +111,49 @@ const bodyStyle = computed(() => ({
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
||||
function trackDrag(event: DragEvent, index: number | null) {
|
||||
event.preventDefault();
|
||||
dragScrollColumn(bodyEl.value, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
|
||||
event.stopPropagation();
|
||||
trackDrag(event, pointerDropIndex(index, event));
|
||||
emit('dragOver', stageId, event);
|
||||
}
|
||||
|
||||
function onColumnDrop(event: DragEvent) {
|
||||
const at = insertAt.value ?? undefined;
|
||||
clearDragUi();
|
||||
emit('drop', props.stage.id, event, at);
|
||||
}
|
||||
|
||||
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit('drop', stageId, event, index);
|
||||
const at = insertAt.value ?? pointerDropIndex(index, event);
|
||||
clearDragUi();
|
||||
emit('drop', stageId, event, at);
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
@@ -174,8 +212,8 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<template>
|
||||
<section
|
||||
:style="columnStyle"
|
||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="emit('drop', stage.id, $event)"
|
||||
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="onColumnDrop"
|
||||
>
|
||||
<div :style="headStyle">
|
||||
<span
|
||||
@@ -260,13 +298,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div
|
||||
v-for="(deal, index) in stage.deals"
|
||||
:key="deal.id"
|
||||
@dragover="onCardDragOver(stage.id, $event)"
|
||||
@dragover="onCardDragOver(stage.id, index, $event)"
|
||||
@drop="onCardDrop(stage.id, index, $event)"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === index)"
|
||||
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
|
||||
/>
|
||||
<DealCard
|
||||
:deal="deal"
|
||||
:accent="stage.color"
|
||||
@@ -274,14 +317,22 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
:peeking="peekId === deal.id"
|
||||
:previewing="previewId === deal.id"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
@detail="emit('detailDeal', $event)"
|
||||
@menu="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
|
||||
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="!stage.deals.length"
|
||||
type="secondary"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
|
||||
const canPayroll = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' ||
|
||||
auth.user?.role === 'admin' ||
|
||||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
);
|
||||
|
||||
const links = computed(() => [
|
||||
{ value: '/finance', label: 'داشبورد', exact: true },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ value: '/finance/reports', label: 'گزارش' },
|
||||
{ value: '/finance/audit', label: 'سوابق' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها' },
|
||||
]);
|
||||
const links = computed(() => {
|
||||
const role = auth.user?.role;
|
||||
const permissions = auth.user?.permissions;
|
||||
const all: Array<{ value: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
|
||||
{ value: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها', section: 'treasury_incomes' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها', section: 'treasury_expenses' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها', section: 'treasury_accounts' },
|
||||
{ value: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||
{ value: '/finance/categories', label: 'دستهبندی', section: 'treasury_categories' },
|
||||
];
|
||||
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||
});
|
||||
|
||||
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
|
||||
|
||||
@@ -42,6 +42,17 @@ const active = computed(() => {
|
||||
return '/finance';
|
||||
});
|
||||
|
||||
watch(
|
||||
active,
|
||||
async () => {
|
||||
await nextTick();
|
||||
scroller.value
|
||||
?.querySelector('.ant-segmented-item-selected')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onChange(value: string | number) {
|
||||
const next = String(value);
|
||||
if (next && next !== route.path) void router.push(next);
|
||||
@@ -50,6 +61,7 @@ function onChange(value: string | number) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="scroller"
|
||||
:style="{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: TreasuryPartyBalanceDto[];
|
||||
settledLabel: string;
|
||||
}>();
|
||||
|
||||
const isMobile = useTreasuryMobile();
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
|
||||
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),
|
||||
},
|
||||
]);
|
||||
|
||||
function kindOf(record: TreasuryPartyBalanceDto) {
|
||||
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list
|
||||
v-if="isMobile"
|
||||
:data-source="rows"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-flex vertical :gap="4" style="width: 100%">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-typography-text strong>{{ item.partyName }}</a-typography-text>
|
||||
<a-typography-text type="secondary">{{ kindOf(item) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">مبلغ کل</a-typography-text>
|
||||
<a-typography-text>{{ toman(item.amount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">{{ settledLabel }}</a-typography-text>
|
||||
<a-typography-text>{{ toman(item.settledAmount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">مانده</a-typography-text>
|
||||
<a-typography-text strong>{{ toman(item.remaining) }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
/>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user