58 Commits
Author SHA1 Message Date
MahdiandCursor de77066cb0 feat(tahesab): add manual sync to refresh linked customer profiles
Expose sync/customers-now and a settings button so existing linked people get national id, address, city, and rasteh without waiting for the schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:54:59 +03:30
MahdiandCursor d23dd20237 feat(tahesab): sync full customer profile fields into CRM people
Pull national id, address, city, and rasteh on import/refresh so person records match Tahesab, and surface them on the person form and profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:31:47 +03:30
MahdiandCursor 8a4bd70ad4 fix(boards): keep kanban card drag from locking after a few moves
Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:58:19 +03:30
MahdiandCursor d87321c7bf fix(activities): type ActivityRow with editPolicy for API builds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:01:16 +03:30
MahdiandCursor 1a05eee46d feat(activities): let creators lock edit and delete to themselves
Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:56:36 +03:30
MahdiandCursor ec9a7cf9c9 feat(deals): plan activities on the deal and create them from the board
Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:06:20 +03:30
MahdiandCursor 4f08ffae36 fix(web): use nowrap for StagePathStrip flex wrap prop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:59:06 +03:30
MahdiandCursor d7cb92bf81 feat(web): add stage path strip with Didar-style move confirm
Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:55:43 +03:30
MahdiandCursor 871d7bba1a fix(activities): keep board filters and make card drops reliable
Return to the filtered desk after marking an activity done, widen the detail title, and stop a stuck column-drag id from blocking card moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:05:26 +03:30
MahdiandCursor f13a1249af feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30
MahdiandCursor fdf1fbe4aa feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 20:10:16 +03:30
MahdiandCursor 6df198321a feat(activities): remove an assignee from the share list
A team, role, or person can be taken off the activity from the progress chips, and ungrouped people use the same chip as grouped members.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 18:43:02 +03:30
MahdiandCursor 4c8604d346 feat(activities): pick assignees by team or role
A team or role adds every member in one step, while each person's completion share stays unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:39:51 +03:30
MahdiandCursor 25a24772fc feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:59:32 +03:30
MahdiandCursor 932dc3f1c1 feat(billing): start demos on the core plan and apply tariff levels
New signups get the core module set with the 7-day demo. Superadmin can apply core, team, organization, or dedicated and the owner's environments take that level's access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:08:14 +03:30
MahdiandCursor 5b26b9156d fix(treasury): restore the money parser import on expenses
The phone layout used the amount parser without importing it, so the web type check blocked the push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:33:17 +03:30
MahdiandCursor d12d7b91cb feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:28:27 +03:30
MahdiandCursor 49459707cb fix(treasury): show a party's net balance as debtor or creditor
Income and expense forms now use the same receivable-minus-payable figure beside the party.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 11:09:51 +03:30
MahdiandCursor ba7f942059 fix(treasury): apply a full receipt or payment to the party's open balance
A settled document of the same amount no longer leaves an existing remaining unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 10:45:59 +03:30
MahdiandCursor 96105468d4 fix(activities): keep each assignee status attached to that person
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:45:32 +03:30
MahdiandCursor 7a8007fff0 feat(activities): keep multi-assignee completion readable for large teams
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:30:05 +03:30
MahdiandCursor da851bd9e5 feat(activities): let every assignee finish their own share
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 20:48:43 +03:30
MahdiandCursor dae3ae3f7e fix(notes): keep card titles visible when the square card is crowded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 18:34:23 +03:30
MahdiandCursor b03d97dd9e fix(activities): show each note as its own dated message
The colon inside the clock was splitting the stamp from the comment, so date, author, text, and attachments now stay together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:39:06 +03:30
MahdiandCursor 8b8d01976d fix(pipeline): hide prior-only sales paths from current work
The board lists a pipeline in current work only when it has a deal in that period, and keeps archive-only paths for the prior-data view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 15:09:49 +03:30
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30
MahdiandCursor 3c9b906bd6 fix(leads): keep the filter panel from shifting the page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:58:18 +03:30
MahdiandCursor ef58922e13 feat(leads): gather filters in one popover like activities
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:24:45 +03:30
MahdiandCursor 06036b00d3 feat(finance): open payroll details from mobile cards
Show each payroll as a card on a phone and slide its details up from the bottom so a short sheet leaves the top of the screen clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 18:13:38 +03:30
MahdiandCursor d33d88e76d feat(finance): show payroll details and search payment periods
Keep long reasons out of the report table, open them with payments in a details view, and let a payment period be found by name or month.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 17:34:12 +03:30
MahdiandCursor 5ef693990c feat(finance): edit payroll with month ranges, reasons, and toman
Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:20:00 +03:30
MahdiandCursor 33f33f9521 fix(notes): keep the card menu above the grid
The menu was clipped by the card, so the lower actions disappeared under the next row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:19:35 +03:30
MahdiandCursor 97f00d7e6d fix(products): open one row menu so edit and other actions run
Desktop and mobile dropdowns shared an id, so both opened and the extra menu closed the click before the action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 11:54:38 +03:30
m.lotfi b8c49293f7 Update .gitea/workflows/build.yml
Fix build.yml for push to registery
2026-11-08 06:37:08 +00:00
MahdiandCursor f36711174e feat(voip): make Daftaresoma setup inbound-first and filter calls by line
Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 13:58:42 +03:30
MahdiandCursor abb1a3a18e feat(voip): add Daftaresoma provider tab with webhook and click-to-call
Map portal call events into CRM logs, store the API token for OutgoingCall, and surface a dedicated settings tab for wiring the webhook URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 12:31:59 +03:30
MahdiandCursor 21d2c69227 feat(leads): expand filters and tidy the list chrome on mobile
Owners can filter by assignee and follow-up status, with extra filters in a drawer, while mobile keeps a compact search-and-filter bar without the stage chip grid.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:04:59 +03:30
150 changed files with 13959 additions and 1971 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+129
View File
@@ -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}"
+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
@@ -0,0 +1,3 @@
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1 @@
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1 @@
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
@@ -0,0 +1,9 @@
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
CREATE TABLE "DataFix" (
"key" TEXT NOT NULL,
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1,6 @@
ALTER TABLE "AccountingConnection"
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "customerSyncLastSlot" TEXT,
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
ADD COLUMN "customerSyncLastSummary" TEXT,
ADD COLUMN "customerSyncLastError" TEXT;
@@ -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';
+69 -2
View File
@@ -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')
+283 -34
View File
@@ -31,6 +31,7 @@ import {
ownerWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
@@ -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 ?? [],
+24 -2
View File
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
export class MoveActivityStageDto {
@IsString()
stageId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -316,7 +322,7 @@ export class CreateActivityDto {
@IsOptional()
@IsString()
@MaxLength(2000)
@MaxLength(20000)
description?: string;
@IsOptional()
@@ -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';
}
+11 -3
View File
@@ -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 } : {}),
});
}
+4
View File
@@ -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 },
});
+34 -1
View File
@@ -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() {
+28
View File
@@ -0,0 +1,28 @@
import type { Prisma } from '@prisma/client';
type PositionUpdate = Prisma.PrismaPromise<unknown>;
/** کارت را در ایندکس خواسته‌شده می‌گذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
export function placeId(ids: string[], movingId: string, index: number): string[] {
const next = ids.filter((id) => id !== movingId);
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
next.splice(at, 0, movingId);
return next;
}
export function sameIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((id, index) => id === right[index]);
}
/** ترتیب ستون را ۰ تا n ذخیره می‌کند. */
export async function writePositions(
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
ids: string[],
update: (id: string, position: number) => PositionUpdate,
) {
const size = 60;
for (let start = 0; start < ids.length; start += size) {
const slice = ids.slice(start, start + size);
await transaction(slice.map((id, offset) => update(id, start + offset)));
}
}
+78 -6
View File
@@ -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 } },
+7 -3
View File
@@ -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([
+2 -1
View File
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+367
View File
@@ -0,0 +1,367 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+33
View File
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null;
}
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+27 -12
View File
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import {
asAddresses,
asCustomFields,
@@ -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}`);
}
}
}
+16
View File
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
}
export class ManualLinkDto {
@@ -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 },
+161 -5
View File
@@ -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')),
);
}
+35
View File
@@ -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 },
+16 -8
View File
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
canonicalIranMobile,
emptyToNull,
normalizeEmail,
normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service';
@@ -458,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({
where: {
organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }],
OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
},
select: { id: true, firstName: true, lastName: true },
})
@@ -641,13 +647,16 @@ export class LeadFormsService {
ownerId: string,
): Promise<string> {
const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile);
const phone = normalizePhone(mapped.phone);
const mobile = storedPhoneNumber(mapped.mobile);
const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
const mobileKeys = phoneIdentityKeys(mobile);
if (mobileKeys.length) {
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
}
const existing = or.length
? await this.prisma.person.findFirst({
where: { organizationId, OR: or },
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
}
function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw);
if (!n) return null;
const d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
return d.startsWith('0') ? d : d;
return d || null;
}
function normalizeDuplicateMode(raw?: string | null): string {
+16
View File
@@ -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,
});
}
+229 -44
View File
@@ -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,
+21
View File
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsIn,
IsObject,
IsOptional,
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
}
export class UpdateOrgRoleDto {
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
@ValidateIf((_, v) => v !== null)
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
}
export class RolePermissionPatchItemDto {
@@ -32,6 +32,8 @@ type RoleRow = {
color: string;
permissions: string[];
dataAccessPolicy?: unknown;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
systemKey: string | null;
parentId: string | null;
createdAt: Date;
@@ -79,6 +81,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -108,6 +112,8 @@ export class OrgRolesService {
description: (dto.description ?? '').trim(),
color: dto.color ?? parent?.color ?? 'teal',
permissions,
treasuryAccountIds: dto.treasuryAccountIds ?? [],
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
parentId,
...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
@@ -135,6 +141,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
},
): Promise<OrgRoleDto> {
this.assertManager(actorRole);
@@ -159,6 +167,12 @@ export class OrgRolesService {
if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
}
if (dto.treasuryAccountIds !== undefined) {
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
}
if (dto.treasuryOwnAccounts !== undefined) {
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
}
if (dto.parentId !== undefined) {
const nextParentId =
@@ -426,6 +440,8 @@ export class OrgRolesService {
color: row.color,
permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
treasuryAccountIds: row.treasuryAccountIds ?? [],
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
systemKey,
parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> {
const orgId = actor.organizationId;
const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
const validMobile = mobileCore.length >= 8 ? mobile : null;
const mobile = storedPhoneNumber(row.personMobile);
const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
}
if (!found && validMobile) {
found = await this.prisma.person.findFirst({
where: { organizationId: orgId, mobile: { contains: mobileCore } },
where: {
organizationId: orgId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true },
});
}
+23 -1
View File
@@ -27,6 +27,7 @@ import {
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -379,7 +380,8 @@ export class DealsService {
}
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position =
let placedIds: string[] | null = null;
let position =
dto.position ??
((
await this.prisma.deal.aggregate({
@@ -387,6 +389,17 @@ export class DealsService {
_max: { position: true },
})
)._max.position ?? -1) + 1;
if (dto.position != null) {
const rows = await this.prisma.deal.findMany({
where: { organizationId, stageId: stage.id },
select: { id: true },
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
const currentIds = rows.map((row) => row.id);
placedIds = placeId(currentIds, deal.id, dto.position);
position = Math.max(0, placedIds.indexOf(deal.id));
if (sameIds(currentIds, placedIds)) placedIds = null;
}
const row = await this.prisma.deal.update({
where: { id },
@@ -402,6 +415,15 @@ export class DealsService {
include: dealInclude,
});
if (placedIds) {
await writePositions(
(ops) => this.prisma.$transaction(ops),
placedIds,
(dealId, nextPosition) =>
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
);
}
if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({
organizationId,
@@ -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')
+39 -8
View File
@@ -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),
}));
}
+6 -1
View File
@@ -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,
+18 -1
View File
@@ -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(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string,
): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string,
): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim();
if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() },
});
let person = await this.prisma.person.findFirst({
where: { organizationId: org.id, mobile },
orderBy: { createdAt: 'asc' },
});
let person = mobileKeys.length
? await this.prisma.person.findFirst({
where: {
organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) {
person = await this.prisma.person.create({
data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
}
private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile);
if (mobile) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } },
where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true },
});
if (byMobile) return byMobile.id;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string;
role: string;
apiKeyId?: string;
apiKeyName?: string;
};
}>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId,
role: auth.role,
apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
};
return true;
}
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate(
rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true },
select: { id: true, organizationId: true, name: true },
});
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return {
organizationId: row.organizationId,
apiKeyId: row.id,
name: row.name,
role: 'owner',
};
}
@@ -1,18 +1,55 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
class CreateExternalLeadDto {
@IsOptional()
@IsString()
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(80)
lastName?: string;
@IsString()
@MinLength(8)
@MaxLength(20)
mobile!: string;
@IsOptional()
@IsString()
@MaxLength(200)
storeName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
source?: string;
}
const LEAD_RATE_WINDOW_MS = 60_000;
const LEAD_RATE_MAX = 60;
const leadHits = new Map<string, number[]>();
class ListQueryDto {
@IsOptional()
@IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1')
@UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly leads: LeadsService,
) {}
@Post('leads')
@HttpCode(200)
createLead(
@CurrentUser()
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
@Body() body: CreateExternalLeadDto,
) {
const bucket = user.apiKeyId || user.organizationId;
const now = Date.now();
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
if (prev.length >= LEAD_RATE_MAX) {
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
}
prev.push(now);
leadHits.set(bucket, prev);
return this.leads.createFromExternal({
organizationId: user.organizationId,
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
firstName: body.firstName,
lastName: body.lastName,
mobile: body.mobile,
storeName: body.storeName,
source: body.source,
});
}
@Get('people')
async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
@Module({
imports: [AuthModule, BillingModule],
imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService],
+3 -2
View File
@@ -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);
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.create({
data: {
...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
this.assertCanWriteRoutine(scope, existing);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
this.assertCanWriteRoutine(scope, src);
const {
id: _id,
createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row;
}
/** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
if (scope.isManager) return dto;
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید');
}
return { ...dto, assigneeId: actor.userId, teamId: null };
}
private assertCanWriteRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.isManager) {
this.assertCanManageRoutine(scope, row);
return;
}
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید');
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
+212
View File
@@ -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),
};
}
+65 -10
View File
@@ -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';
}
+218 -7
View File
@@ -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 ?? '',
})),
};
}
+97 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; isActive?: boolean },
) {
return this.treasury.updateCategory(user, id, body);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteCategory(user, id);
}
@Get('accounts')
accounts(
@CurrentUser() user: Actor,
@@ -61,7 +75,18 @@ export class TreasuryController {
updateAccount(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
@Body()
body: {
name?: string;
type?: 'bank' | 'cash' | 'petty';
bankName?: string | null;
accountRef?: string | null;
notes?: string | null;
isActive?: boolean;
responsibleName?: string | null;
openingBalance?: string | number;
openingDate?: string;
},
) {
return this.treasury.updateAccount(user, id, body);
}
@@ -81,6 +106,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"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service';
@Module({
imports: [AuthModule, NotificationsModule],
imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController],
providers: [
TreasuryService,
File diff suppressed because it is too large Load Diff
+6
View File
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
@IsOptional()
@IsObject()
dataAccessOverride?: Record<string, unknown> | null;
/** null = ارث از نقش */
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
treasuryAccessOverride?: Record<string, unknown> | null;
}
+43 -9
View File
@@ -14,7 +14,12 @@ import type {
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import {
DATA_SCOPES,
DATA_VISIBILITIES,
parseDataAccessPolicy,
parseUserTreasuryAccess,
} from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
@@ -47,6 +52,7 @@ type MembershipRow = {
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
treasuryAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
@@ -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>
+9 -2
View File
@@ -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();
+3
View File
@@ -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"
+12 -4
View File
@@ -126,6 +126,7 @@ defineExpose({ refresh: load });
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<Teleport to="body">
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
@@ -170,7 +171,9 @@ defineExpose({ refresh: load });
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="warnOpen" class="layer">
<div class="backdrop" @click="warnOpen = false" />
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
@@ -186,6 +189,7 @@ defineExpose({ refresh: load });
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
</div>
</div>
</Teleport>
</div>
</template>
@@ -275,9 +279,12 @@ defineExpose({ refresh: load });
.layer {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
}
.backdrop {
@@ -289,7 +296,8 @@ defineExpose({ refresh: load });
.modal {
position: relative;
width: min(420px, calc(100vw - 2rem));
max-height: min(90vh, 640px);
max-height: calc(100vh - 2rem);
margin: auto;
overflow: auto;
background: #fff;
border-radius: 16px;
@@ -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>
+12 -3
View File
@@ -11,6 +11,7 @@ import type {
TeamDto,
TeamMemberRole,
UpdatePriorArchiveCutoffRequest,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
@@ -279,8 +280,13 @@ async function removeTeam(id: string) {
}
}
const showTreasury = computed(() => {
const modules = auth.user?.enabledModules;
return !modules || modules.includes('treasury');
});
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const draftTreasury = ref<UserTreasuryAccess | null>(null);
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
@@ -323,6 +329,7 @@ function openAccessEditor(u: OrgUserDto) {
draftAccess.value = hasOverride
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess);
draftTreasury.value = u.treasuryAccessOverride ?? null;
expandedAccess.value = u.id;
}
@@ -333,8 +340,8 @@ async function saveUserAccess(userId: string) {
try {
const payload =
accessMode.value === 'inherit'
? { dataAccessOverride: null }
: { dataAccessOverride: draftAccess.value };
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null;
@@ -528,8 +535,10 @@ onMounted(() => {
سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact
/>
<div class="actions">
+214 -21
View File
@@ -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>
+115 -30
View File
@@ -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