50 Commits
Author SHA1 Message Date
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
133 changed files with 12166 additions and 1738 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,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';
+60 -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")
@@ -524,6 +536,7 @@ model Company {
customFields Json @default("{}")
groupLinks CompanyGroupLink[]
people Person[]
financialDocuments FinancialDocument[]
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -881,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)
@@ -1841,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[]
@@ -2330,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)
@@ -2350,6 +2382,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt])
@@index([organizationId, status])
@@index([personId])
@@index([companyId])
@@index([userId])
}
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -2430,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?
@@ -2936,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[]
@@ -2955,6 +3002,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt
@@index([organizationId, enabled])
@@index([organizationId, provider])
}
model VoipCallLog {
@@ -2969,6 +3017,8 @@ model VoipCallLog {
status String @default("completed")
fromNumber String?
toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String?
activityId String?
durationSec Int?
@@ -2993,6 +3043,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId])
@@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt])
@@ -3769,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;
@@ -438,8 +438,16 @@ export class ActivitiesController {
}
@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')
+244 -30
View File
@@ -52,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 };
@@ -82,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[];
@@ -186,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({
@@ -534,8 +591,24 @@ export class ActivitiesService {
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: {
@@ -550,6 +623,9 @@ export class ActivitiesService {
: stage.kind === 'open'
? null
: undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
},
include,
});
@@ -675,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,
@@ -743,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) {
@@ -812,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 },
@@ -839,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);
@@ -852,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({
@@ -881,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));
@@ -891,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));
@@ -946,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 },
@@ -966,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('فایلی ارسال نشده است');
}
@@ -1060,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 },
});
@@ -1108,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();
@@ -1418,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 ?? [],
+16
View File
@@ -384,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 {
@@ -484,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() {
+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 = {
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null;
lastHealthOk: boolean | null;
lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date;
updatedAt: Date;
}) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk,
lastError: row.lastError,
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
customerSyncLastSummary: row.customerSyncLastSummary,
customerSyncLastError: row.customerSyncLastError,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
let customerSyncHours: number[] | undefined;
if (dto.customerSyncHours !== undefined) {
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
if (new Set(hours).size !== hours.length) {
throw new BadRequestException('ساعت‌های بروزرسانی نباید تکراری باشند.');
}
customerSyncHours = [...hours].sort((a, b) => a - b);
}
let enc = {
ciphertext: row.tokenCiphertext,
iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
},
});
return this.toDto(updated);
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabPersonLinkService } from './person-link.service';
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
let hour = Number(read('hour'));
if (!Number.isFinite(hour) || hour === 24) hour = 0;
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
return { hour, slot };
}
@Injectable()
export class TahesabCustomerSyncScheduler {
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
private busy = false;
constructor(
private readonly prisma: PrismaService,
private readonly links: TahesabPersonLinkService,
) {}
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
async tick() {
if (process.env.NODE_ENV === 'test' || this.busy) return;
this.busy = true;
try {
const { hour, slot } = tehranSyncSlot();
const connections = await this.prisma.accountingConnection.findMany({
where: {
provider: 'tahesab',
enabled: true,
customerSyncHours: { has: hour },
},
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
});
for (const connection of connections) {
if (connection.customerSyncLastSlot === slot) continue;
const claimed = await this.prisma.accountingConnection.updateMany({
where: {
id: connection.id,
OR: [
{ customerSyncLastSlot: null },
{ customerSyncLastSlot: { not: slot } },
],
},
data: { customerSyncLastSlot: slot },
});
if (claimed.count !== 1) continue;
await this.run(connection.organizationId, connection.id, connection.label);
}
} catch (err) {
this.logger.warn(
`بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${
err instanceof Error ? err.message : err
}`,
);
} finally {
this.busy = false;
}
}
private async run(organizationId: string, connectionId: string, label: string) {
try {
const result = await this.links.syncAllCustomers(organizationId, connectionId);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: result.summary,
customerSyncLastError: result.error,
},
});
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
} catch (err) {
const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: null,
customerSyncLastError: message,
},
});
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
}
}
}
+10
View File
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional()
@IsBoolean()
allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
}
export class ManualLinkDto {
@@ -8,7 +8,12 @@ import {
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source';
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
import {
canonicalIranMobile,
phoneIdentityKeys,
storedPhoneNumber,
} from '../../contacts/normalize';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service';
import {
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
function canonicalMobileUpdate(
incoming: string | null,
current: { mobile: string | null; phone: string | null },
): { mobile?: string; phone?: string } {
const mobile =
canonicalIranMobile(incoming) ??
canonicalIranMobile(current.mobile) ??
canonicalIranMobile(current.phone);
if (!mobile) return {};
const phone = canonicalIranMobile(current.phone);
return phone ? { mobile, phone } : { mobile };
}
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000;
@@ -419,6 +438,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 +587,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 +598,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 +645,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 +684,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;
@@ -604,28 +711,48 @@ export class TahesabPersonLinkService {
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
...canonicalMobileUpdate(mobile, existing),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند.
const byMobile = mobile
? await this.prisma.person.findFirst({
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile }, { phone: mobile }],
accountingLinks: { none: { connectionId: dto.connectionId } },
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
include: {
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
orderBy: { createdAt: 'asc' },
})
: null;
: [];
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
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();
@@ -646,6 +773,7 @@ export class TahesabPersonLinkService {
importSource: 'tahesab',
sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
},
});
personId = byMobile.id;
@@ -693,18 +821,177 @@ export class TahesabPersonLinkService {
});
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
personId: string,
moshtariCode: number,
row: Record<string, unknown>,
) {
const existing = await this.getPerson(organizationId, personId);
const picked = pickName(row).trim();
const named = picked
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
: null;
const nameChanged =
named != null &&
namesDiffer(
existing.firstName,
existing.lastName,
named.firstName,
named.lastName,
);
await this.prisma.person.update({
where: { id: personId },
data: {
...(named
? {
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
...(nameChanged
? {
previousFirstName: existing.firstName || null,
previousLastName: existing.lastName || null,
}
: {}),
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
}
/**
* همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند.
* کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود.
*/
async syncAllCustomers(organizationId: string, connectionId: string) {
const { api } = await this.connections.getApi(organizationId, connectionId);
let page = 1;
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let 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('، ');
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) };
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
duplicate,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
}
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -825,6 +1112,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 =
@@ -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,
});
}
+199 -34
View File
@@ -38,12 +38,23 @@ import {
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> = {
@@ -55,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 {
@@ -130,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) => [
@@ -149,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 } : {}),
},
};
}
@@ -182,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);
@@ -231,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> {
@@ -323,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) {
@@ -966,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;
@@ -996,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,
@@ -1110,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 } },
@@ -1170,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,
@@ -1212,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,
@@ -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 },
});
}
@@ -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),
};
}
+20 -10
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
@@ -16,7 +17,12 @@ 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('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true;
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const permissions = permissionsForTreasury(
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('به این بخش مدیریت مالی دسترسی ندارید.');
}
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
}
}
function permissionsForTreasury(
rolePermissions: string[],
override: ReturnType<typeof parseUserTreasuryAccess>,
): string[] {
if (!override) return rolePermissions;
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
function 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' {
+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 ?? '',
})),
};
}
+85 -2
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,
@@ -92,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,
@@ -195,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,
@@ -232,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,
+635 -25
View File
@@ -13,12 +13,19 @@ import type {
FinancialCategoryDto,
FinancialDocumentDto,
FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared';
import { CompaniesService } from '../contacts/companies.service';
import { PeopleService } from '../contacts/people.service';
import { dateToJalali } from '../common/jalali';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -65,6 +72,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async ensureCategories(organizationId: string) {
@@ -112,19 +121,68 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> {
const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
try {
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
name,
kind: input.kind,
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async updateCategory(
actor: Actor,
id: string,
input: { name?: string; isActive?: boolean },
): Promise<FinancialCategoryDto> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
const name = input.name == null ? current.name : input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.update({
where: { id: current.id },
data: {
name,
...(input.isActive == null ? {} : { isActive: input.isActive }),
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
await this.prisma.financialCategory.delete({ where: { id: current.id } });
return { ok: true };
}
async listAccounts(
@@ -302,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
...(from || to
? {
occurredAt: {
@@ -320,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from },
},
select: { direction: true, amount: true },
@@ -462,7 +522,10 @@ export class TreasuryService {
bookedAt?: string;
dueAt?: string | null;
categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null;
method?: string | null;
reference?: string | null;
@@ -470,15 +533,21 @@ export class TreasuryService {
settleAmount?: string | number | null;
accountId?: string | null;
settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
},
): Promise<FinancialDocumentDto> {
const title = input.title.trim();
const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0)
: dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
}
@@ -496,7 +565,25 @@ export class TreasuryService {
}
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const settlement = input.settlement === true;
if (settlement) {
if (party.partyKind === 'other') {
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
}
if (!settle.equals(amount) || !settle.greaterThan(0)) {
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
}
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
}
const payExisting = settlement;
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
actor.organizationId,
input.kind,
input.bookedAt,
bookedAt,
);
const created = await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
@@ -506,12 +593,15 @@ export class TreasuryService {
bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null,
personId: input.personId || null,
partyName: input.partyName?.trim() || null,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null,
method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null,
reference,
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
@@ -527,11 +617,50 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
reference: input.reference?.trim() || null,
reference,
createdById: actor.userId,
},
});
}
if (payExisting && input.accountId) {
const leftover = await this.allocatePartyBalance(tx, actor, {
party,
sourceId: created.id,
kind: input.kind,
amount: settle,
accountId: input.accountId,
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
});
if (leftover.greaterThan(0)) {
const opposite = input.kind === 'income' ? 'expense' : 'income';
const oppositeRef = await this.nextDocumentReference(
tx,
actor.organizationId,
opposite,
input.bookedAt,
bookedAt,
);
await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
kind: opposite,
title,
amount: leftover,
bookedAt,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
status: 'open',
reference: oppositeRef,
notes: `source:${created.id}`,
createdById: actor.userId,
},
});
}
}
return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id },
include: { category: true, account: true, movements: true },
@@ -540,6 +669,365 @@ export class TreasuryService {
return this.toDocumentDto(doc);
}
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
private async nextDocumentReference(
tx: Prisma.TransactionClient,
organizationId: string,
kind: 'income' | 'expense',
bookedAtInput: string | undefined,
bookedAt: Date,
) {
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
const when = match
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
: bookedAt;
const { jy, jm, jd } = dateToJalali(when);
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
const latest = await tx.financialDocument.findFirst({
where: { organizationId, reference: { startsWith: prefix } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const current = Number(latest?.reference?.slice(prefix.length));
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
const term = q?.trim() ?? '';
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
const [people, companies, users] = await Promise.all([
this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
},
select: { id: true, firstName: true, lastName: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.company.findMany({
where: {
organizationId: actor.organizationId,
...(contains ? { name: contains } : {}),
},
select: { id: true, name: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}),
this.prisma.user.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
...(contains ? { fullName: contains } : {}),
},
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
take: 20,
}),
]);
return {
people: people.map((row) => ({
id: row.id,
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
})),
companies,
users: users.map((row) => ({ id: row.id, name: row.fullName })),
};
}
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
const firstName = input.firstName?.trim() ?? '';
const lastName = input.lastName?.trim() ?? '';
if (`${firstName} ${lastName}`.trim().length < 2) {
throw new BadRequestException('نام شخص را وارد کنید.');
}
const person = await this.people.create(actor, { firstName, lastName });
return { id: person.id, name: person.fullName, kind: 'person' as const };
}
async createPartyCompany(actor: Actor, input: { name?: string }) {
const name = input.name?.trim() ?? '';
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
const company = await this.companies.create(actor, { name });
return { id: company.id, name: company.name, kind: 'company' as const };
}
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
select: {
amount: true,
partyKind: true,
personId: true,
companyId: true,
userId: true,
partyName: true,
movements: { select: { amount: true, voidedAt: true } },
},
});
const buckets = new Map<string, {
key: string;
partyKind: FinancialPartyKind | null;
partyName: string;
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);
if (!rawRemaining.greaterThan(0)) continue;
const linkedId = row.personId || row.companyId || row.userId;
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
? row.partyKind
: null;
const key = partyKind && linkedId
? `${partyKind}:${linkedId}`
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
const current = buckets.get(key) ?? {
key,
partyKind,
partyName: row.partyName?.trim() || 'بدون طرف حساب',
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current);
}
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
const onDebtorSide = party.net.greaterThan(0);
if (kind === 'income' && !onDebtorSide) continue;
if (kind === 'expense' && onDebtorSide) continue;
const remaining = party.net.abs();
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
const current = buckets.get(party.key) ?? {
key: party.key,
partyKind: party.partyKind,
partyName: party.partyName,
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(remaining);
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
current.partyName = party.partyName;
}
buckets.set(party.key, current);
}
return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
partyKind: row.partyKind,
partyName: row.partyName,
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
documentRemaining: money(row.documentRemaining),
}));
}
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
async collectPartyBalance(
actor: Actor,
input: {
kind: 'income' | 'expense';
partyKind: 'person' | 'company' | 'user';
partyId: string;
amount: string | number;
accountId: string;
occurredAt?: string;
},
): Promise<{ applied: string; documentCount: number }> {
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const partyId = input.partyId.trim();
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
}
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
const scope = await this.accountScope(actor);
const docs = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: input.kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.partyKind,
...(input.partyKind === 'person' ? { personId: partyId } : {}),
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
...(input.partyKind === 'user' ? { userId: partyId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const open = docs
.map((doc) => {
const remaining = doc.amount.minus(this.settledOf(doc.movements));
return { doc, remaining };
})
.filter((row) => row.remaining.greaterThan(0));
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
if (!available.greaterThan(0)) {
throw new BadRequestException(
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
);
}
if (amount.greaterThan(available)) {
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
}
if (input.kind === 'expense') {
const balance = await this.accountBalance(account.id);
if (balance.lessThan(amount)) {
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
}
}
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
let left = amount;
const touched: string[] = [];
await this.prisma.$transaction(async (tx) => {
for (const row of open) {
if (!left.greaterThan(0)) break;
const slice = left.lessThan(row.remaining) ? left : row.remaining;
const status = slice.equals(row.remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
documentId: row.doc.id,
direction: input.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt,
memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده',
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.doc.id },
data: { status, accountId: account.id },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.doc.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
touched.push(row.doc.id);
left = left.minus(slice);
}
});
await this.prisma.financialAuditLog.create({
data: {
organizationId: actor.organizationId,
entityType: 'document',
entityId: touched[0] ?? partyId,
action: 'settle',
after: {
amount: amount.toFixed(2),
accountId: account.id,
documentIds: touched,
partyId,
},
actorUserId: actor.userId,
},
}).catch(() => undefined);
return { applied: money(amount), documentCount: touched.length };
}
private async resolveParty(
actor: Actor,
input: {
partyKind?: FinancialPartyKind | null;
personId?: string | null;
companyId?: string | null;
userId?: string | null;
},
) {
const kind = input.partyKind;
if (kind === 'other') {
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
}
if (kind === 'person') {
const id = input.personId?.trim();
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
const person = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, firstName: true, lastName: true },
});
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
return {
partyKind: 'person' as const,
personId: person.id,
companyId: null,
userId: null,
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
};
}
if (kind === 'company') {
const id = input.companyId?.trim();
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
const company = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, name: true },
});
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
return {
partyKind: 'company' as const,
personId: null,
companyId: company.id,
userId: null,
partyName: company.name,
};
}
if (kind === 'user') {
const id = input.userId?.trim();
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
const user = await this.prisma.user.findFirst({
where: { id, organizationId: actor.organizationId },
select: { id: true, fullName: true },
});
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
return {
partyKind: 'user' as const,
personId: null,
companyId: null,
userId: user.id,
partyName: user.fullName,
};
}
throw new BadRequestException('طرف حساب را انتخاب کنید.');
}
async settleDocument(
actor: Actor,
id: string,
@@ -619,16 +1107,51 @@ export class TreasuryService {
});
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date();
await this.prisma.$transaction([
this.prisma.financialMovement.updateMany({
await this.prisma.$transaction(async (tx) => {
const allocations = await tx.financialMovement.findMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
select: { documentId: true },
});
await tx.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now },
}),
this.prisma.financialDocument.update({
});
await tx.financialMovement.updateMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
data: { voidedAt: now },
});
await tx.financialDocument.update({
where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}),
]);
});
const excess = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
notes: `source:${doc.id}`,
voidedAt: null,
status: { in: ['open', 'partial'] },
},
include: { movements: { select: { voidedAt: true } } },
});
for (const child of excess) {
if (child.movements.some((row) => !row.voidedAt)) continue;
await tx.financialDocument.update({
where: { id: child.id },
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
});
}
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
for (const documentId of affected) {
const row = await tx.financialDocument.findUnique({
where: { id: documentId },
include: { movements: { select: { amount: true, voidedAt: true } } },
});
if (!row || row.voidedAt || row.status === 'void') continue;
const settled = this.settledOf(row.movements);
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
}
});
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id },
include: { category: true, account: true, movements: true },
@@ -803,6 +1326,17 @@ export class TreasuryService {
}
const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._sum.amount);
const invoiceSales = await invoiceSalesInPeriod(
this.prisma,
actor.organizationId,
from,
to,
);
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
else payables = payables.plus(party.net.abs());
}
const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0),
@@ -812,7 +1346,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables),
payables: money(payables),
totalBalance: money(totalBalance),
@@ -822,6 +1356,76 @@ export class TreasuryService {
};
}
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
private async allocatePartyBalance(
tx: Prisma.TransactionClient,
actor: Actor,
input: {
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
sourceId: string;
kind: 'income' | 'expense';
amount: Prisma.Decimal;
accountId: string;
occurredAt: Date;
memo: string;
},
): Promise<Prisma.Decimal> {
const scope = await this.accountScope(actor);
const openDocs = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
id: { not: input.sourceId },
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.party.partyKind,
...(input.party.personId ? { personId: input.party.personId } : {}),
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
...(input.party.userId ? { userId: input.party.userId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const ordered = [
...openDocs.filter((row) => row.kind === input.kind),
...openDocs.filter((row) => row.kind !== input.kind),
];
let left = input.amount;
for (const row of ordered) {
if (!left.greaterThan(0)) break;
const remaining = row.amount.minus(this.settledOf(row.movements));
if (!remaining.greaterThan(0)) continue;
const slice = left.lessThan(remaining) ? left : remaining;
const status = slice.equals(remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
documentId: row.id,
direction: row.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: 'allocation',
occurredAt: input.occurredAt,
memo: input.memo,
reference: `alloc:${input.sourceId}`,
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.id },
data: { status },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
left = left.minus(slice);
}
return left;
}
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements
.filter((row) => !row.voidedAt)
@@ -830,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null },
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true },
});
return rows.reduce(
@@ -905,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null },
where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true, kind: true, occurredAt: true },
});
let balance = new Prisma.Decimal(0);
@@ -948,7 +1552,10 @@ export class TreasuryService {
bookedAt: Date;
dueAt: Date | null;
categoryId: string | null;
partyKind?: string | null;
personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null;
accountId: string | null;
method: string | null;
@@ -975,7 +1582,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId,
categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName,
accountId: row.accountId,
accountName: row.account?.name ?? null,
+15 -3
View File
@@ -104,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 } });
@@ -449,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,
@@ -456,7 +462,7 @@ export class UsersService {
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
@@ -528,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({
@@ -558,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 },
});
+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>
+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>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import type {
@@ -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,6 +65,9 @@ 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);
@@ -222,6 +227,8 @@ function onMenuActivity(id: string) {
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
// اگر شناسهٔ درگ ستون گیر کرده باشد، دراپ کارت را ستون‌جابه‌جا می‌بیند
draggingColumnId.value = null;
draggingId.value = deal.id;
overlayDeal.value = deal;
const sid = findStageOf(deal.id);
@@ -235,12 +242,19 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
// بستن پیش‌نمایش وسط dragstart گاهی خودِ درگ را لغو می‌کند
if (quickId.value) void nextTick(() => closeQuick());
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
const endedId = draggingId.value;
// drop معمولاً قبل از dragend است؛ کمی تأخیر تا fallback شناسه از بین نرود
window.setTimeout(() => {
if (draggingId.value !== endedId) return;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -292,6 +306,21 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
stageId: toStageId,
position: insertAt,
});
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');
@@ -310,18 +339,24 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
const transfer = event.dataTransfer?.getData('text/plain') || '';
const cardId = draggingId.value;
const columnId = draggingColumnId.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;
draggingColumnId.value = null;
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || (!transfer && !cardId && columnId)) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
return;
}
if (!raw || moving.value) return;
await moveCard(raw, stageId, index);
const activityId = (!isStageDrag && transfer) || cardId || '';
if (!activityId || moving.value) return;
await moveCard(activityId, stageId, index);
}
async function persistOrder(ids: string[]) {
@@ -404,7 +439,7 @@ async function onAddStage(name: string) {
}
}
function openActivity(id: string) {
function activityQuery(id: string) {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
@@ -424,7 +459,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>
@@ -440,23 +499,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-start="(id: string) => { draggingId = null; overlayDeal = null; draggingColumnId = id; }"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@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>
+101 -34
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
@@ -18,6 +18,7 @@ import {
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';
@@ -31,6 +32,9 @@ const props = withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selected?: boolean;
peeking?: boolean;
/** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */
previewing?: boolean;
}>(),
{ copy: () => DEAL_BOARD_COPY },
);
@@ -39,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: [];
@@ -47,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,
);
@@ -74,13 +84,40 @@ 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: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary,
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>
@@ -122,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"
@@ -196,22 +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-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
<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-tooltip>
</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, pointerDropIndex } 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, pointerDropIndex(index, event));
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,5 +1,5 @@
<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';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => {
const role = auth.user?.role;
@@ -22,7 +23,7 @@ const links = computed(() => {
{ 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' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
});
@@ -41,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);
@@ -49,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>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Button as AButton,
Flex as AFlex,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
import { UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import CompanyFormView from '@/views/CompanyFormView.vue';
import PersonFormView from '@/views/PersonFormView.vue';
const VNodes = defineComponent({
props: { vnodes: { type: Object, required: true } },
render() {
return this.vnodes;
},
});
const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId');
type Choice = { id: string; name: string };
const people = ref<Choice[]>([]);
const companies = ref<Choice[]>([]);
const users = ref<Choice[]>([]);
const labels = ref<Record<string, string>>({});
const loading = ref(false);
const selectOpen = ref(false);
const personOpen = ref(false);
const companyOpen = ref(false);
const selectValue = computed(() => {
if (kind.value === 'other') return 'other';
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
return undefined;
});
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
if (kind.value !== group || !partyId.value) return list;
if (list.some((row) => row.id === partyId.value)) return list;
const name = labels.value[`${group}:${partyId.value}`] || 'انتخاب‌شده';
return [{ id: partyId.value, name }, ...list];
}
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
const userOptions = computed(() => withCurrent(users.value, 'user'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ثبت ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق بود.';
}
let timer = 0;
function onSearch(value: string) {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void load(value);
}, 250);
}
async function load(q = '') {
loading.value = true;
try {
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
params: { q: q.trim() || undefined },
});
people.value = data.people;
companies.value = data.companies;
users.value = data.users;
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
} catch (e) {
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
} finally {
loading.value = false;
}
}
function onChange(value: unknown) {
if (typeof value !== 'string' || !value) {
kind.value = undefined;
partyId.value = undefined;
return;
}
if (value === 'other') {
kind.value = 'other';
partyId.value = undefined;
return;
}
const splitAt = value.indexOf(':');
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
const id = value.slice(splitAt + 1);
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
kind.value = nextKind;
partyId.value = id;
}
function onDropdown(open: boolean) {
selectOpen.value = open;
}
function openPersonForm() {
selectOpen.value = false;
personOpen.value = true;
}
function openCompanyForm() {
selectOpen.value = false;
companyOpen.value = true;
}
function onPersonCreated(person?: PersonDto) {
personOpen.value = false;
if (!person?.id) return;
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
labels.value[`person:${person.id}`] = name;
kind.value = 'person';
partyId.value = person.id;
}
function onCompanyCreated(company?: CompanyDto) {
companyOpen.value = false;
if (!company?.id) return;
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
labels.value[`company:${company.id}`] = company.name;
kind.value = 'company';
partyId.value = company.id;
}
onMounted(() => {
void load();
});
</script>
<template>
<a-flex vertical :gap="4">
<a-select
:open="selectOpen"
show-search
@dropdownVisibleChange="onDropdown"
allow-clear
:filter-option="false"
:loading="loading"
:value="selectValue"
:placeholder="placeholder"
style="width: 100%"
@search="onSearch"
@change="onChange"
>
<a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option
v-for="row in peopleOptions"
:key="`person:${row.id}`"
:value="`person:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="companyOptions.length" label="شرکت‌ها">
<a-select-option
v-for="row in companyOptions"
:key="`company:${row.id}`"
:value="`company:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<a-select-opt-group v-if="userOptions.length" label="کاربران">
<a-select-option
v-for="row in userOptions"
:key="`user:${row.id}`"
:value="`user:${row.id}`"
:label="row.name"
>
{{ row.name }}
</a-select-option>
</a-select-opt-group>
<template #dropdownRender="{ menuNode }">
<v-nodes :vnodes="menuNode" />
<a-flex @mousedown.prevent>
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
</a-flex>
</template>
</a-select>
<a-typography-text v-if="kind === 'other'" type="secondary">
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمی‌ماند.
</a-typography-text>
</a-flex>
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
<PersonFormView
v-if="personOpen"
embedded
@saved="onPersonCreated"
@cancel="personOpen = false"
/>
</UiModal>
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
<CompanyFormView
v-if="companyOpen"
embedded
@saved="onCompanyCreated"
@cancel="companyOpen = false"
/>
</UiModal>
</template>
@@ -0,0 +1,52 @@
import { ref, watch, type Ref } from 'vue';
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی می‌کند تا کاربر خودش آن را عوض کند. */
export function useSettleDefault(
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
) {
const dirty = ref(false);
let mirroring = false;
function locked() {
return form.value.partyKind === 'other';
}
function copy() {
mirroring = true;
form.value.settleAmount = form.value.amount;
mirroring = false;
}
watch(
() => form.value.amount,
() => {
if (locked() || !dirty.value) copy();
},
{ flush: 'sync' },
);
watch(
() => form.value.settleAmount,
() => {
if (!mirroring && !locked()) dirty.value = true;
},
{ flush: 'sync' },
);
watch(
() => form.value.partyKind,
(kind) => {
if (kind === 'other') {
dirty.value = false;
copy();
}
},
{ flush: 'sync' },
);
return {
reset() {
dirty.value = false;
},
};
}
+12 -6
View File
@@ -21,6 +21,7 @@ import type {
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import {
canOpenPath,
firstAllowedFinancePath,
hasSectionPermission,
sectionPermissionForPath,
} from '@/lib/section-permissions';
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| perms.includes('treasury')
|| perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
return (
moduleAllowed(card.module ?? 'treasury')
&& Boolean(firstAllowedFinancePath(auth.user?.role, perms))
);
}
const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null;
const cards = hub.cards.filter((card) => cardAllowedForNav(card));
const financeTo = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
const cards = hub.cards
.filter((card) => cardAllowedForNav(card))
.map((card) => {
if (!financeTo || !card.to.startsWith('/finance') || card.to === financeTo) return card;
return { ...card, to: financeTo };
});
if (
hub.key === 'customers' &&
isMobileNav.value &&
+23
View File
@@ -0,0 +1,23 @@
import type { ActivityDto, ActivityEditPolicy } from '@mento/shared';
type ActivityAccessUser = {
id?: string | null;
role?: string | null;
} | null | undefined;
/** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */
export function canMutateActivity(
activity: Pick<
ActivityDto,
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
> | null | undefined,
user: ActivityAccessUser,
): boolean {
if (!activity || !user?.id) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
if (activity.createdBy?.id === user.id) return true;
const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator';
if (policy !== 'assignees') return false;
if (activity.assignee?.id === user.id) return true;
return (activity.assignees ?? []).some((a) => a.id === user.id);
}
+161
View File
@@ -0,0 +1,161 @@
import { jalaliToDate } from '@/lib/jalali';
export type ActivityThreadEntry = {
at: Date | null;
dateLabel: string;
timeLabel: string;
author: string;
body: string;
/** شناسهٔ فایل‌های چسبیده به این یادداشت (عکس/صوت/فایل) */
attachmentIds?: string[];
};
const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/;
const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i;
const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000;
/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل می‌کند. */
export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null {
const text = (raw ?? '').replace(/\r\n/g, '\n').trim();
if (!text) return null;
const entries: ActivityThreadEntry[] = [];
const preamble: string[] = [];
let current: ActivityThreadEntry | null = null;
let sawStamp = false;
for (const line of text.split('\n')) {
const trimmed = line.trim();
const stamp = trimmed.match(STAMP);
if (stamp) {
sawStamp = true;
if (current) entries.push(trimEntry(current));
current = {
at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''),
dateLabel: (stamp[1] ?? '').trim(),
timeLabel: (stamp[2] ?? '').trim(),
author: (stamp[3] ?? '').trim(),
body: '',
attachmentIds: [],
};
continue;
}
const attach = trimmed.match(ATTACHMENTS);
if (attach && current) {
current.attachmentIds = attach[1]!
.split(',')
.map((id) => id.trim())
.filter(Boolean);
continue;
}
if (current) current.body += `${current.body ? '\n' : ''}${line}`;
else preamble.push(line);
}
if (current) entries.push(trimEntry(current));
if (!sawStamp) return null;
const head = preamble.join('\n').trim();
if (head) {
entries.unshift({
at: null,
dateLabel: '',
timeLabel: '',
author: '',
body: head,
attachmentIds: [],
});
}
return entries;
}
/** برگرداندن متن ذخیره‌شونده از نظرهای جدا. */
export function serializeActivityThread(entries: ActivityThreadEntry[]): string {
return entries
.map((entry) => {
const ids = (entry.attachmentIds ?? []).filter(Boolean);
const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : '';
if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim();
const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`;
return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`;
})
.filter((chunk) => chunk.trim())
.join('\n\n');
}
/** فایل‌ها را به یادداشت‌ها می‌چسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */
export function bindThreadMedia<T extends { id: string; createdAt: string }>(
entries: ActivityThreadEntry[],
images: T[],
windowMs = COMMENT_MEDIA_WINDOW_MS,
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; loose: T[] } {
const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] }));
const byId = new Map(images.map((image) => [image.id, image]));
const used = new Set<string>();
blocks.forEach((block) => {
for (const id of block.attachmentIds ?? []) {
const image = byId.get(id);
if (!image || used.has(id)) continue;
block.media.push(image);
used.add(id);
}
});
for (const image of images) {
if (used.has(image.id)) continue;
const at = new Date(image.createdAt).getTime();
if (Number.isNaN(at)) continue;
let best = -1;
let bestDelta = Number.POSITIVE_INFINITY;
blocks.forEach((block, index) => {
if (!block.at) return;
const delta = block.at.getTime() - at;
if (delta >= -60_000 && delta < bestDelta) {
best = index;
bestDelta = delta;
}
});
if (best >= 0 && bestDelta <= windowMs) {
blocks[best]!.media.push(image);
used.add(image.id);
}
}
const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY;
const preamble = blocks.find((block) => !block.at);
const loose: T[] = [];
for (const image of images) {
if (used.has(image.id)) continue;
const at = new Date(image.createdAt).getTime();
if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image);
else loose.push(image);
}
return { blocks, loose };
}
function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry {
return {
...entry,
body: entry.body.trim(),
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
};
}
function latinDigits(value: string): string {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const faIndex = fa.indexOf(ch);
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
});
}
function stampToDate(dateLabel: string, timeLabel: string): Date | null {
const date = latinDigits(dateLabel).match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
if (!date) return null;
const at = jalaliToDate(Number(date[1]), Number(date[2]), Number(date[3]));
if (Number.isNaN(at.getTime())) return null;
const time = latinDigits(timeLabel).match(/(\d{1,2}):(\d{2})/);
if (time) at.setHours(Number(time[1]), Number(time[2]), 0, 0);
return at;
}
+49 -2
View File
@@ -20,6 +20,8 @@ export type PipelineDeal = {
assigneeNames?: string[];
ownerId?: string | null;
assigneeIds?: string[];
personId?: string | null;
companyId?: string | null;
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
@@ -34,6 +36,10 @@ export type PipelineUiCopy = {
itemMenuAria: string;
listColTitle: string;
hideAmount?: boolean;
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه به‌جای ورود مستقیم به جزئیات */
quickView?: boolean;
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
quickActivity?: boolean;
};
export const DEAL_BOARD_COPY: PipelineUiCopy = {
@@ -43,6 +49,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله',
quickActivity: true,
};
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
@@ -63,6 +70,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت',
hideAmount: true,
quickView: true,
};
export type PipelineStage = {
@@ -182,8 +190,8 @@ export function mapBoardToStages(
expectedCloseAt: string | null;
createdAt: string;
promotedFromPriorAt: string | null;
person: { fullName: string } | null;
company: { name: string } | null;
person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
@@ -236,6 +244,8 @@ export function mapBoardToStages(
assigneeNames,
ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
personId: deal.person?.id ?? null,
companyId: deal.company?.id ?? null,
searchFields: [
deal.notes,
deal.company?.name,
@@ -431,6 +441,43 @@ export function pointerDropIndex(index: number, event: DragEvent): number {
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
}
const DRAG_SCROLL_EDGE = 72;
const DRAG_SCROLL_MAX = 16;
let dragScrollEl: HTMLElement | null = null;
let dragScrollY = 0;
let dragScrollRaf = 0;
function dragScrollTick() {
dragScrollRaf = 0;
const el = dragScrollEl;
if (!el) return;
const rect = el.getBoundingClientRect();
let delta = 0;
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
}
if (!delta || el.scrollHeight <= el.clientHeight) return;
el.scrollTop += delta;
dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت می‌کند تا مقصد دیده شود. */
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
dragScrollEl = el;
dragScrollY = clientY;
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
export function stopDragScroll() {
dragScrollEl = null;
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
dragScrollRaf = 0;
}
export function moveStageToIndex(
stages: PipelineStage[],
stageId: string,
+24
View File
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
return treasurySectionAllowed(role, permissions, section);
}
const FINANCE_ENTRIES: Array<{ path: string; section: TreasurySectionKey }> = [
{ path: '/finance', section: 'treasury_dashboard' },
{ path: '/finance/incomes', section: 'treasury_incomes' },
{ path: '/finance/expenses', section: 'treasury_expenses' },
{ path: '/finance/receivables', section: 'treasury_receivables' },
{ path: '/finance/accounts', section: 'treasury_accounts' },
{ path: '/finance/payroll', section: 'treasury_payroll' },
{ path: '/finance/recurring', section: 'treasury_recurring' },
{ path: '/finance/reports', section: 'treasury_reports' },
{ path: '/finance/audit', section: 'treasury_audit' },
{ path: '/finance/categories', section: 'treasury_categories' },
];
/** اولین صفحهٔ مالی که این نقش واقعاً می‌تواند باز کند. */
export function firstAllowedFinancePath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
): string | null {
for (const entry of FINANCE_ENTRIES) {
if (treasurySectionAllowed(role, permissions, entry.section)) return entry.path;
}
return null;
}
export function canOpenPath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
+14 -12
View File
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
import {
canOpenFinancePath,
canOpenPath,
firstAllowedFinancePath,
sectionPermissionForPath,
} from '@/lib/section-permissions';
export const router = createRouter({
history: createWebHistory(),
@@ -330,7 +335,8 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
},
},
{
@@ -350,7 +356,8 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.',
treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
},
},
{
@@ -369,8 +376,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.',
treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
},
},
],
@@ -681,13 +688,8 @@ router.beforeEach((to) => {
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true;
}
const exactHome = to.path === '/finance' || to.path === '/finance/';
if (
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
if (next && next !== to.path) return { path: next };
}
return { name: 'home' };
}
+6
View File
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return {
token,
user,
+98 -41
View File
@@ -52,7 +52,10 @@ import type {
} from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { apiError } from '@/lib/routines';
import { message } from 'ant-design-vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -201,6 +204,7 @@ const page = computed(() => {
const loading = ref(false);
const error = ref('');
const notice = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]);
@@ -445,23 +449,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
return query;
}
function activityRoute(id: string, edit = false, columnStageId?: string) {
if (!edit) {
return {
name: 'activity-detail' as const,
params: { id },
query: deskDetailQuery(columnStageId),
};
}
const query: Record<string, string> = {};
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
function activityRoute(id: string, columnStageId?: string) {
return {
name: 'activity-edit' as const,
name: 'activity-detail' as const,
params: { id },
query,
query: deskDetailQuery(columnStageId),
};
}
@@ -837,6 +829,30 @@ function onFacetDone() {
openCol.value = null;
}
function assigneePeopleOf(item: ActivityDto) {
if (item.assignees?.length) return item.assignees;
return item.assignee ? [item.assignee] : [];
}
function needsEveryone(item: ActivityDto) {
return item.completionMode === 'all' && assigneePeopleOf(item).length > 1;
}
function canMarkShare(item: ActivityDto) {
if (item.status !== 'open') return false;
if (!needsEveryone(item)) return true;
const mine = auth.user?.id;
if (!mine) return false;
return (
assigneePeopleOf(item).some((person) => person.id === mine) &&
!item.completedByIds?.includes(mine)
);
}
function completeLabel(item: ActivityDto) {
return needsEveryone(item) ? 'انجام سهم من' : 'انجام شد';
}
async function complete(id: string) {
menuId.value = null;
boardMenuId.value = null;
@@ -913,10 +929,27 @@ function clearSelect() {
selectedIds.value = new Set();
}
function canDeleteActivity(item: ActivityDto | null | undefined) {
return canMutateActivity(item, auth.user);
}
function activityById(id: string) {
return (
items.value.find((row) => row.id === id) ||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
);
}
function askDeleteActivity(id: string, title?: string) {
menuId.value = null;
boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id];
}
@@ -926,8 +959,24 @@ function askBulkDelete() {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
const selected = [...selectedIds.value];
const allowed = selected.filter((id) => {
const row = activityById(id);
return !row || canDeleteActivity(row);
});
const blocked = selected.length - allowed.length;
if (!allowed.length) {
message.warning('شما مجوز حذف این فعالیت‌ها را ندارید.');
error.value = 'شما مجوز حذف این فعالیت‌ها را ندارید.';
return;
}
if (blocked > 0) {
message.warning(
`${toFaDigits(blocked)} مورد به‌خاطر نبود مجوز حذف کنار گذاشته شد.`,
);
}
deleteSingleTitle.value = '';
deleteActivityIds.value = [...selectedIds.value];
deleteActivityIds.value = allowed;
}
async function confirmDeleteActivities() {
@@ -937,16 +986,24 @@ async function confirmDeleteActivities() {
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
const failed = results.filter((r) => r.status === 'rejected');
deleteActivityIds.value = [];
clearSelect();
kanbanReload.value += 1;
await refreshAll();
if (failed) {
if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value =
failed === ids.length
? 'حذف فعالیت‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
failed.length === ids.length
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
}
} finally {
activityDeleting.value = false;
@@ -1503,6 +1560,7 @@ onUnmounted(() => {
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="notice" class="ok">{{ notice }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<div v-if="selectMode" class="select-bar">
@@ -1532,6 +1590,7 @@ onUnmounted(() => {
@stages-changed="onStagesChanged"
@add="openAddInStage"
@error="(msg: string) => (error = msg)"
@notice="(msg: string) => (notice = msg)"
@menu-deal="onBoardMenu"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@@ -1749,16 +1808,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
<button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف
</button>
</UiDropdownMenu>
@@ -1839,16 +1900,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
<button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف
</button>
</UiDropdownMenu>
@@ -1895,18 +1958,11 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
>
مشاهده
</RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button"
@@ -1916,6 +1972,7 @@ onUnmounted(() => {
انتقال به کار جاری
</button>
<button
v-if="canDeleteActivity(boardMenuActivity)"
type="button"
role="menuitem"
class="danger"
File diff suppressed because it is too large Load Diff
+118 -1
View File
@@ -129,6 +129,8 @@ const personId = ref('');
const leadId = ref('');
const dealId = ref('');
const assigneeIds = ref<string[]>([]);
const completionMode = ref<'any' | 'all'>('any');
const editPolicy = ref<'creator' | 'assignees'>('creator');
const isDone = ref(false);
const needsNote = ref(false);
const reminderAt = ref('');
@@ -440,6 +442,8 @@ function resetForm() {
personId.value = '';
dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
completionMode.value = 'any';
editPolicy.value = 'creator';
pickedRoleIds.value = [];
pickedTeamIds.value = [];
isDone.value = false;
@@ -528,6 +532,8 @@ async function load() {
: auth.user?.id
? [auth.user.id]
: [];
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
refreshPickedGroups();
isDone.value = data.status === 'done';
await loadSavedPhotos(data);
@@ -541,6 +547,16 @@ async function load() {
personId.value = incomingPerson;
}
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
const incomingDeal =
typeof route.query.dealId === 'string' ? route.query.dealId : '';
if (incomingDeal) {
dealId.value = incomingDeal;
const linkedDeal = deals.value.find((d) => d.id === incomingDeal);
if (linkedDeal?.person?.id && !personId.value) {
await ensurePersonLoaded(linkedDeal.person.id);
personId.value = linkedDeal.person.id;
}
}
const incomingDef =
typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId)
@@ -605,6 +621,8 @@ function payload(): CreateActivityRequest {
leadId: leadId.value || null,
assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null,
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
editPolicy: editPolicy.value,
};
}
@@ -978,7 +996,9 @@ async function submit(next: 'detail' | 'new' = 'detail') {
savedId = data.id;
createdActivityId.value = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
await api.patch(`/activities/${savedId}/complete`, undefined, {
params: { force: '1' },
});
}
} else {
await api.patch(`/activities/${savedId}`, {
@@ -1269,6 +1289,20 @@ watch([id, isNew], () => void load(), { immediate: true });
</li>
</ul>
</div>
<div v-if="assigneeIds.length > 1" class="completion-mode">
<span>نحوهٔ انجام</span>
<label>
<input v-model="completionMode" type="radio" value="any" />
با انجام یکی کافی است
</label>
<label>
<input v-model="completionMode" type="radio" value="all" />
همهٔ مسئول‌ها باید انجام دهند
</label>
<p v-if="completionMode === 'all'" class="completion-hint">
هر کدام از همین افراد باید جداگانه سهم خودش را انجام دهد.
</p>
</div>
<small class="hint">
{{
canAssignOthers
@@ -1302,6 +1336,29 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
</div>
<div class="card">
<div class="field">
<label>ویرایش و حذف</label>
<div class="completion-mode">
<label>
<input v-model="editPolicy" type="radio" value="creator" />
فقط خودم (ایجادکننده)
</label>
<label>
<input v-model="editPolicy" type="radio" value="assignees" />
همهٔ مسئولان
</label>
</div>
<small class="hint">
{{
editPolicy === 'assignees'
? 'مسئولان هم می‌توانند این فعالیت را ویرایش یا حذف کنند.'
: 'فقط ایجادکننده می‌تواند این فعالیت را ویرایش یا حذف کند.'
}}
</small>
</div>
</div>
<div class="card">
<div class="field">
<div class="label-row">
@@ -1799,6 +1856,66 @@ watch([id, isNew], () => void load(), { immediate: true });
font-size: 0.75rem;
}
.completion-mode {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
}
.completion-mode > span {
width: 100%;
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 600;
}
.completion-mode label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.2rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
}
.completion-mode label:has(input:checked) {
border-color: #14b8a6;
background: #f0fdfa;
color: #0f766e;
}
.completion-mode input {
width: 14px;
height: 14px;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
flex: 0 0 14px;
accent-color: #0d9488;
background: transparent;
}
.completion-hint {
width: 100%;
margin: 0.15rem 0 0;
color: #64748b;
font-size: 0.75rem;
line-height: 1.5;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
+2 -2
View File
@@ -27,7 +27,7 @@ const props = withDefaults(
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
const route = useRoute();
const router = useRouter();
@@ -152,7 +152,7 @@ async function submit() {
if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload());
if (props.embedded) {
emit('saved');
emit('saved', data);
return;
}
await router.replace({ name: 'company-detail', params: { id: data.id } });
+63
View File
@@ -18,6 +18,7 @@ import {
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Modal,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
@@ -44,9 +45,11 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedActivitiesPanel from '@/components/activities/RelatedActivitiesPanel.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import type {
@@ -104,6 +107,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
const loading = ref(false);
const saving = ref(false);
const stageMoving = ref(false);
const error = ref('');
const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']);
@@ -197,6 +201,43 @@ const pipelineSelectOptions = computed(() =>
const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })),
);
const stagePathItems = computed(() => stages.value.map((s) => ({ id: s.id, name: s.name })));
function onStageSelect(nextId: string) {
if (!nextId || nextId === stageId.value || stageMoving.value || saving.value) return;
const target = stages.value.find((stage) => stage.id === nextId);
if (!target) return;
const prev = stageId.value;
Modal.confirm({
title: 'جابه‌جایی مرحله',
content: `به مرحله «${target.name}» منتقل می‌شود. انجام شود؟`,
okText: 'انجام بده',
cancelText: 'انجام نده',
centered: true,
async onOk() {
stageId.value = nextId;
if (isNew.value || !id.value) return;
stageMoving.value = true;
error.value = '';
try {
await api.patch(`/deals/${id.value}/move`, { stageId: nextId });
} catch (e) {
stageId.value = prev;
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'جابه‌جایی مرحله ناموفق بود.');
} else {
error.value = 'جابه‌جایی مرحله ناموفق بود.';
}
throw e;
} finally {
stageMoving.value = false;
}
},
});
}
const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })),
);
@@ -665,6 +706,20 @@ onMounted(load);
</a-space>
</a-flex>
<div
v-if="stagePathItems.length"
:style="{ marginBottom: '16px' }"
>
<StagePathStrip
:stages="stagePathItems"
:value="stageId"
:loading="stageMoving"
:disabled="saving || loading"
aria-label="مراحل مسیر فروش"
@select="onStageSelect"
/>
</div>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit">
@@ -960,6 +1015,14 @@ onMounted(load);
</a-form-item>
</a-card>
<RelatedActivitiesPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
:deal-id="id"
:person-id="personId || null"
:company-id="companyId || null"
:deal-title="title"
/>
<RelatedTasksPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"

Some files were not shown because too many files have changed in this diff Show More