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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
106 changed files with 10006 additions and 1559 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,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';
+37 -2
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -883,6 +894,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime? reminderSentAt DateTime?
completedAt DateTime? completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([]) tags String[] @default([])
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -2447,8 +2464,11 @@ model FinancialPayroll {
period String period String
baseAmount Decimal @db.Decimal(18, 2) baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2) bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2) deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2) payable Decimal @db.Decimal(18, 2)
dueDate DateTime dueDate DateTime
notes String? notes String?
@@ -2953,18 +2973,28 @@ model MontifyWebhookDelivery {
@@index([connectionId, createdAt]) @@index([connectionId, createdAt])
} }
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) /// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده)
model VoipConnection { model VoipConnection {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String label String
/// generic | daftareshoma
provider String @default("generic")
enabled Boolean @default(true) enabled Boolean @default(true)
webhookSecretCiphertext String? webhookSecretCiphertext String?
webhookSecretIv String? webhookSecretIv String?
webhookSecretTag String? webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic)
clickToCallUrl String? clickToCallUrl String?
/// توکن Bearer API دفتر شما (رمزنگاری‌شده)
apiTokenCiphertext String?
apiTokenIv String?
apiTokenTag String?
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
fromNumber String?
/// خط/داخلی سازمان در دفتر شما (caller_extension)
callerExtension String?
lastEventAt DateTime? lastEventAt DateTime?
lastError String? lastError String?
calls VoipCallLog[] calls VoipCallLog[]
@@ -2972,6 +3002,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -2986,6 +3017,8 @@ model VoipCallLog {
status String @default("completed") status String @default("completed")
fromNumber String? fromNumber String?
toNumber String? toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String? personId String?
activityId String? activityId String?
durationSec Int? durationSec Int?
@@ -3010,6 +3043,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId]) @@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt]) @@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([activityId]) @@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt]) @@index([summaryStatus, summaryNextAttemptAt])
@@ -438,8 +438,16 @@ export class ActivitiesController {
} }
@Patch(':id/complete') @Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) { complete(
return this.activitiesService.complete(user.organizationId, id, user); @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') @Delete(':id')
+244 -30
View File
@@ -52,6 +52,43 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' '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'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -82,6 +119,9 @@ type ActivityRow = {
reminderNotifySms: boolean; reminderNotifySms: boolean;
reminderSentAt: Date | null; reminderSentAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string; importSource?: string;
promotedFromPriorAt?: Date | null; promotedFromPriorAt?: Date | null;
tags: string[]; tags: string[];
@@ -186,22 +226,39 @@ export class ActivitiesService {
return { stages, types }; 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( async summary(
organizationId: string, organizationId: string,
actor: ActivityActor, actor: ActivityActor,
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
@@ -534,8 +591,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { data: {
@@ -550,6 +623,9 @@ export class ActivitiesService {
: stage.kind === 'open' : stage.kind === 'open'
? null ? null
: undefined, : undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
}, },
include, include,
}); });
@@ -675,6 +751,9 @@ export class ActivitiesService {
assignees: assigneeIds.length assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) } ? { connect: assigneeIds.map((id) => ({ id })) }
: undefined, : undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId, createdById: userId,
}, },
include, include,
@@ -743,8 +822,23 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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 = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) { if ('boardId' in dto) {
@@ -812,6 +906,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined; let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({ const current = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -839,6 +934,7 @@ export class ActivitiesService {
); );
data.assigneeId = assigneeIds[0]!; data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else { } else {
const assigneeId = emptyToNull(dto.assigneeId ?? null); const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +948,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId; data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }]; if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = []; else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
} }
} }
if (dto.status !== undefined) { const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
data.status = dto.status; const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
data.completedAt = if (modeChanged) data.completionMode = nextMode;
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; 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({ const row = await this.prisma.activity.update({
@@ -881,7 +1009,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)), 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); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +1019,74 @@ export class ActivitiesService {
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { status: 'done', completedAt: new Date() }, data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include, include,
}); });
if (existing?.status !== 'done') { if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -946,6 +1130,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, id, actor); 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({ const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId }, where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true }, select: { storageKey: true, sizeBytes: true },
@@ -966,6 +1162,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivityImageDto> { ): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor); 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) { if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است'); throw new BadRequestException('فایلی ارسال نشده است');
} }
@@ -1060,6 +1268,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, activityId, actor); 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({ const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId }, where: { id: imageId, activityId, organizationId },
}); });
@@ -1108,16 +1328,7 @@ export class ActivitiesService {
typeRows: TypeRow[] = [], typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim(); const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim(); const facetTitle = facets?.title?.trim();
@@ -1418,6 +1629,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms, reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null, reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual', importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [], tags: row.tags ?? [],
+16
View File
@@ -384,6 +384,14 @@ export class CreateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
} }
export class UpdateActivityDto { export class UpdateActivityDto {
@@ -484,4 +492,12 @@ export class UpdateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; 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, StorageUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
}, },
}); });
const role = membership?.role ?? user.role; 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 orgName = membership?.organization.name;
const sub = const sub =
subscription ?? subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage, storage,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions) permissions: permissionsWithTreasuryOverride(
? membership.orgRole.permissions.filter((item) => typeof item === 'string') rolePermissions,
: []) as AuthUserDto['permissions'], parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({ const peers = await this.prisma.teamMember.findMany({
where: { where: {
teamId: { in: memberships.map((m) => m.teamId) }, teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
}, },
select: { userId: true }, select: { userId: true },
}); });
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules, defaultEnabledModules,
isPermanentBillingPlan, isPermanentBillingPlan,
isOrgModuleKey, isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service'; import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
} }
defaultModulesForNewOrg(): OrgModuleKey[] { 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() { async countExpiringSoon() {
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess, SectionDataAccess,
UserRole, UserRole,
} from '@mento/shared'; } 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 type { Prisma } from '@prisma/client';
import { import {
activitiesDeepWhere, activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere, notesDeepWhere,
projectsDeepWhere, projectsDeepWhere,
routinesDeepWhere, routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere, ticketsDeepWhere,
} from './data-access'; } from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>; 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 با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */ /** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T { export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope]; const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */ /** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere( export function ownerWhere(
actor: ScopedActor, actor: DeepAccessActor,
scope: DataScope = 'contacts', scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> { ): { 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); const ids = scopedOwnerIds(actor, scope);
if (!ids) return {}; if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! }; if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
} }
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ /** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere( export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
actor: ScopedActor, const deep = deepOwnerIds(actor, 'activities');
): Prisma.ActivityWhereInput { const ids =
const ids = scopedOwnerIds(actor, 'activities'); deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {}; if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return { return {
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
+7 -3
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []), ...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
]; ];
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId, AND: [
...customersCompanyWhere(actor), {
...(and.length ? { AND: and } : {}), organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
+9 -5
View File
@@ -218,11 +218,15 @@ export class PeopleService implements OnModuleInit {
if (disc) and.push(disc); if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
return { return {
organizationId: actor.organizationId, AND: [
...customersPersonWhere(actor), {
...(moods.length ? { mood: { in: moods } } : {}), organizationId: actor.organizationId,
...(sources.length ? { importSource: { in: sources } } : {}), ...customersPersonWhere(actor),
...(and.length ? { AND: and } : {}), },
...(moods.length ? [{ mood: { in: moods } }] : []),
...(sources.length ? [{ importSource: { in: sources } }] : []),
...and,
],
}; };
} }
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import { import {
assertFactorCode, assertFactorCode,
buildFactorCode, buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index'); assert(bank[12] === fc, 'bank factor index');
} }
function testMoshtariProfile() {
const row = {
CodeMelli: '00112312311',
Address: 'خیابان ولیعصر',
City: 'تهران',
Rasteh: 2,
BDate: null,
};
assert(pickNationalId(row) === '00112312311', 'national id');
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
assert(pickCity(row) === 'تهران', 'city');
assert(pickRasteh(row) === 2, 'rasteh');
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
const patch = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: { keep: true },
});
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
const fields = patch.customFields as Record<string, unknown>;
assert(fields.nationalId === '00112312311', 'cf national');
assert(fields.city === 'تهران', 'cf city');
assert(fields.rasteh === 2, 'cf rasteh');
assert(fields.keep === true, 'cf merge');
const emptyKeep = tahesabProfilePatch(
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
{
birthDate: new Date('2000-01-01'),
addresses: ['قدیمی'],
customFields: { nationalId: '111' },
},
);
assert(emptyKeep.addresses == null, 'empty does not wipe address');
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
assert(emptyKeep.birthDate == null, 'existing birth kept');
}
function main() { function main() {
testCryptoRoundtrip(); testCryptoRoundtrip();
testFactorCode(); testFactorCode();
testMobile(); testMobile();
testMandehNormalize(); testMandehNormalize();
testSanadBuilders(); testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK'); console.log('tahesab unit checks: OK');
} }
+6
View File
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number; pageSize?: number;
} }
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto { export class ExportPeopleToTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@@ -0,0 +1,113 @@
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../../common/excel';
export function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function trimStr(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === '-1') return null;
return s;
}
/** کد ملی از پاسخ لیست مشتری ته‌حساب */
export function pickNationalId(row: Record<string, unknown>): string | null {
return (
trimStr(row.CodeMelli) ??
trimStr(row.CodeMeli) ??
trimStr(row.codeMeli) ??
trimStr(row.NationalId) ??
null
);
}
export function pickAddress(row: Record<string, unknown>): string | null {
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
}
export function pickCity(row: Record<string, unknown>): string | null {
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
}
/** رسته حساب در ته‌حساب (عدد یا رشته) */
export function pickRasteh(row: Record<string, unknown>): string | number | null {
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
if (raw == null || raw === '') return null;
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (!s || s === '-1') return null;
const n = Number(s);
return Number.isFinite(n) && String(n) === s ? n : s;
}
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return {};
}
function asAddressList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => (typeof a === 'string' ? a.trim() : ''))
.filter(Boolean);
}
/**
* فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند.
* مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند.
*/
export function tahesabProfilePatch(
row: Record<string, unknown>,
existing: {
birthDate: Date | null;
addresses: unknown;
customFields: unknown;
},
): Prisma.PersonUpdateInput {
const data: Prisma.PersonUpdateInput = {};
const birthDate = pickBirthDate(row);
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
const address = pickAddress(row);
const city = pickCity(row);
if (address) {
const line = city ? `${address}، ${city}` : address;
data.addresses = [line] as Prisma.InputJsonValue;
} else if (city && asAddressList(existing.addresses).length === 0) {
data.addresses = [city] as Prisma.InputJsonValue;
}
const fields = asCustomFieldRecord(existing.customFields);
let fieldsChanged = false;
const nationalId = pickNationalId(row);
if (nationalId) {
fields.nationalId = nationalId;
fieldsChanged = true;
}
if (city) {
fields.city = city;
fieldsChanged = true;
}
if (address) {
fields.address = address;
fieldsChanged = true;
}
const rasteh = pickRasteh(row);
if (rasteh != null) {
fields.rasteh = rasteh;
fieldsChanged = true;
}
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
return data;
}
@@ -26,8 +26,9 @@ import {
SearchTahesabDto, SearchTahesabDto,
SyncGroupsPageDto, SyncGroupsPageDto,
} from './dto'; } from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel'; import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */ /** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
} }
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined { function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined; if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim()); const first = raw.find((a) => typeof a === 'string' && a.trim());
@@ -438,6 +428,77 @@ export class TahesabPersonLinkService {
}; };
} }
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) { async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi( const { api } = await this.connections.getApi(
organizationId, organizationId,
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
}); });
const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); 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; const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return { return {
...row, ...row,
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
: null, : 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing), ...canonicalMobileUpdate(mobile, existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود. // تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile); const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length const matches = mobileKeys.length
? await this.prisma.person.findMany({ ? await this.prisma.person.findMany({
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode; const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.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 = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef, sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile), ...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
} else { } else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({ const created = await this.prisma.person.create({
data: { data: {
organizationId, organizationId,
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
birthDate: pickBirthDate(row), birthDate: (profile.birthDate as Date | undefined) ?? null,
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
}, },
}); });
personId = created.id; personId = created.id;
} }
} }
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) }; return { personId, link: this.linkDto(link) };
} }
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ /** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer( private async refreshLinkedCustomer(
organizationId: string, organizationId: string,
connectionId: string, connectionId: string,
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: String(moshtariCode), sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
await this.applyTahesabGroup(organizationId, connectionId, personId, row); await this.applyTahesabGroup(organizationId, connectionId, personId, row);
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
let emptyStreak = 0; let emptyStreak = 0;
let imported = 0; let imported = 0;
let updated = 0; let updated = 0;
let duplicate = 0;
let failed = 0; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
const maxPages = 300; const maxPages = 300;
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
); );
updated += 1; updated += 1;
} else { } else {
await this.importFromTahesab( const created = await this.importFromTahesab(
organizationId, organizationId,
{ connectionId, moshtariCode: code }, { connectionId, moshtariCode: code },
{ api, row }, { api, row },
{ skipJob: true }, { skipJob: true },
); );
imported += 1; if (created.duplicateOf) duplicate += 1;
else imported += 1;
} }
} catch (err) { } catch (err) {
failed += 1; failed += 1;
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
} }
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`; const summary = [
`${imported} وارد شد`,
`${updated} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
const error = errors.length ? errors.join('؛ ') : null;
await this.prisma.accountingSyncJob.create({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId, connectionId,
kind: 'customer-sync', kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue, result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: summary,
customerSyncLastError: error,
}, },
}); });
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`); this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { return {
imported, imported,
updated, updated,
duplicate,
failed, failed,
summary, summary,
error: errors.length ? errors.join('؛ ') : null, error,
}; };
} }
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); const row = rowsByCode.get(moshtariCode);
if (birthDate) { if (row) {
await this.prisma.person.updateMany({ await this.refreshLinkedCustomer(
where: { id: linkedPersonId, birthDate: null }, organizationId,
data: { birthDate }, dto.connectionId,
}); linkedPersonId,
moshtariCode,
row,
);
} }
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode }, { connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined, 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 }); items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) { } catch (e) {
const message = const message =
@@ -37,6 +37,7 @@ import {
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto, SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto, UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto); return this.links.syncGroupsPage(user.organizationId, dto);
} }
/** همهٔ مشتریان ته‌حساب: تازه‌ها وارد، لینک‌شده‌ها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) به‌روز */
@Post('sync/customers-now')
@Roles('owner', 'admin')
syncCustomersNow(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAllCustomersDto,
) {
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
}
@Get('sync/unlinked-people') @Get('sync/unlinked-people')
@Roles('owner', 'admin') @Roles('owner', 'admin')
unlinkedPeople( unlinkedPeople(
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { AiService } from '../../ai/ai.service'; import { AiService } from '../../ai/ai.service';
import { NotesService } from '../../notes/notes.service'; import { NotesService } from '../../notes/notes.service';
import { PrismaService } from '../../prisma/prisma.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'; import { SttClientService } from './stt-client.service';
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
@@ -17,6 +20,8 @@ export class CallSummaryService {
private readonly stt: SttClientService, private readonly stt: SttClientService,
private readonly ai: AiService, private readonly ai: AiService,
private readonly notes: NotesService, private readonly notes: NotesService,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
@Cron('*/1 * * * *') @Cron('*/1 * * * *')
@@ -74,7 +79,31 @@ export class CallSummaryService {
); );
return; 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({ await this.prisma.voipCallLog.update({
where: { id }, where: { id },
data: { transcript }, data: { transcript },
+159 -4
View File
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, ''); return (value ?? '').replace(/\D/g, '');
@@ -57,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService, private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, 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' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -91,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -127,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || 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 matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -193,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -213,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -240,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber); 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({ const link = await this.prisma.voipCallLog.findUnique({
where: { where: {
connectionId_externalCallId: { connectionId_externalCallId: {
@@ -261,10 +296,67 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: 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); const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست'); 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({ const conn = await this.prisma.voipConnection.findFirst({
where: { where: {
organizationId, organizationId,
@@ -318,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; 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 { private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status); return ['answered', 'completed', 'missed', 'failed'].includes(status);
} }
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest, CreateVoipConnectionRequest,
UpdateVoipConnectionRequest, UpdateVoipConnectionRequest,
VoipConnectionDto, VoipConnectionDto,
VoipProvider,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable() @Injectable()
export class VoipConnectionsService { export class VoipConnectionsService {
constructor( constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> { ): Promise<VoipConnectionDto> {
const label = dto.label?.trim(); const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است'); 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 secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null; 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({ const row = await this.prisma.voipConnection.create({
data: { data: {
organizationId, organizationId,
label, label,
provider,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
clickToCallUrl, clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv, webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag, webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
}, },
}); });
return this.toDto(row, 0); return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) { if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); 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) { if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim(); const wh = dto.webhookSecret?.trim();
if (!wh) { if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag; 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({ const row = await this.prisma.voipConnection.update({
where: { id: existing.id }, where: { id: existing.id },
data, 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( verifySignature(
rawBody: string | Buffer, rawBody: string | Buffer,
signature: string | undefined, signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: { row: {
id: string; id: string;
label: string; label: string;
provider?: string | null;
enabled: boolean; enabled: boolean;
webhookSecretCiphertext: string | null; webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null; clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null; lastEventAt: Date | null;
lastError: string | null; lastError: string | null;
createdAt: Date; createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return { return {
id: row.id, id: row.id,
label: row.label, label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled, enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext), webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl, clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError, lastError: row.lastError,
callCount, 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 { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80) @MaxLength(80)
label!: string; label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; 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() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); 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(); const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) { if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
} }
const timeoutMs = Number( const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000, this.config.get<string>('STT_TIMEOUT_MS') || 180000,
); );
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || ''; const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData(); const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], { const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType, type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token; if (token) headers['X-STT-Token'] = token;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), waitMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto, UpdateVoipConnectionDto,
VoipClickToCallDto, VoipClickToCallDto,
} from './dto'; } from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip') @Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: 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') @Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); 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' }; 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 }; return { ok: true, ...result };
} }
} }
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service'; import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service'; import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller'; import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -258,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -291,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
+89 -16
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,22 +171,77 @@ export class LeadsService {
if (opts.hasMobile) { if (opts.hasMobile) {
and.push({ mobile: { not: '' } }); 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 { return {
organizationId: actor.organizationId, AND: [
...leadAccessWhere(actor), {
archivedAt: opts.archived ? { not: null } : null, organizationId: actor.organizationId,
...(opts.stageId ? { stageId: opts.stageId } : {}), ...leadAccessWhere(actor),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), archivedAt: opts.archived ? { not: null } : null,
...(opts.unassigned ? { assigneeId: null } : {}), ...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.region ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
? { region: { contains: opts.region, mode: 'insensitive' } } ...(opts.region
: {}), ? { region: { contains: opts.region, mode: 'insensitive' } }
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), : {}),
...(opts.source ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
? { source: { contains: opts.source, mode: 'insensitive' } } ...(opts.source
: {}), ? { source: { contains: opts.source, mode: 'insensitive' } }
...(and.length ? { AND: and } : {}), : {}),
},
...(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 } : {}),
},
}; };
} }
@@ -194,8 +259,12 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
sort?: string; sort?: string;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Promise<PaginatedResponse<LeadDto>> { ): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId); await this.stages.ensureDefaults(actor.organizationId);
@@ -243,7 +312,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
@@ -1116,7 +1189,7 @@ export class LeadsService {
newStage newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0), : Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }), this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length convertedIds.length
? this.prisma.lead.count({ ? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } }, where: { ...base, stageId: { in: convertedIds } },
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, 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: { async createMembership(data: {
userId: string; userId: string;
organizationId: string; organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number; commissionPercent?: number;
isActive?: boolean; isActive?: boolean;
}) { }) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({ return this.prisma.membership.create({
data: { data: {
userId: data.userId, userId: data.userId,
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {} constructor(private readonly pipelinesService: PipelinesService) {}
@Get() @Get()
list(@CurrentUser() user: { organizationId: string }) { list(
return this.pipelinesService.list(user.organizationId); @CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
} }
@Get('board') @Get('board')
+39 -8
View File
@@ -102,24 +102,55 @@ export class PipelinesService {
return this.toPipelineDto(created); 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); await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({ const rows = await this.prisma.pipeline.findMany({
where: { organizationId }, where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } }, include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
}); });
const counts = await this.prisma.deal.groupBy({ const lens = actor?.priorLens;
by: ['pipelineId'], const lensActive = forLens && (lens === 'current' || lens === 'archive');
where: { organizationId }, const access = actor ? dealAccessWhere(actor) : {};
_count: { _all: true }, 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( const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]), 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), ...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, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } 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 { export class PlatformLoginDto {
@IsString() @IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null; quotaGb!: number | null;
} }
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto { export class UpdateOrgModulesDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { import {
ApplyBillingPlanDto, ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto, SetStorageQuotaDto,
SetUserCapDto, SetUserCapDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers); 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') @Patch('organizations/:id/modules')
updateOrganizationModules( updateOrganizationModules(
@Param('id') id: string, @Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformStatsDto, PlatformStatsDto,
PlatformUserDto, PlatformUserDto,
ProductPlanCode,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { isBusinessField } from '@mento/shared'; import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId); 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) { async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({ const existing = await this.prisma.organization.findUnique({
where: { id }, where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) { if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId); await this.billing.assertSeatAvailable(row.organizationId, row.userId);
} }
if (!isActive && row.role === 'owner') { if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); 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') { if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
row.organizationId, row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
assigneeWhere, assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance'); const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor); const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime(); const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1); const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId); const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date(); const now = new Date();
const { jy, jm } = dateToJalali(now); const { jy, jm } = dateToJalali(now);
+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 { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { import {
parseUserTreasuryAccess, parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed, treasurySectionAllowed,
type TreasurySectionKey, type TreasurySectionKey,
} from '@mento/shared'; } from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { 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; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true; 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) const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String) ? membership.orgRole.permissions.map(String)
: []; : [];
const permissions = permissionsForTreasury( const permissions = permissionsWithTreasuryOverride(
rolePermissions, rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride), parseUserTreasuryAccess(membership?.treasuryAccessOverride),
); );
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || ''; 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))) { if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.'); throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
} }
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
} }
} }
function permissionsForTreasury( /** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
rolePermissions: string[], function isLookupForDocuments(method: string, url: string): boolean {
override: ReturnType<typeof parseUserTreasuryAccess>, if (method !== 'GET' && method !== 'HEAD') return false;
): string[] { const path = (url.split('?')[0] || '').replace(/\/+$/, '');
if (!override) return rolePermissions; return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
} }
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' { function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
+180 -6
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); return new Prisma.Decimal(value ?? 0);
@@ -12,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) { function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2); 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) { function parseDate(iso?: string | null) {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d; 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) { function periodKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -36,9 +46,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -55,8 +71,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: string | number; baseAmount: string | number;
benefits?: string | number; benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number; bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number; deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string; dueDate?: string;
notes?: string | null; notes?: string | null;
}, },
@@ -81,14 +100,23 @@ export class TreasuryOpsService {
period: input.period.trim(), period: input.period.trim(),
baseAmount: base, baseAmount: base,
benefits, benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus, bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions, deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable, payable,
dueDate: parseDate(input.dueDate) ?? new Date(), dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, 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 } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -124,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id); const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); 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([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -145,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor); 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) { async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({ const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
@@ -327,6 +472,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', 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.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +558,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -475,12 +627,22 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: Prisma.Decimal; baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal; benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal; bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal; deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; 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, fullName: string,
) { ) {
@@ -493,13 +655,25 @@ export class TreasuryOpsService {
period: row.period, period: row.period,
baseAmount: money(row.baseAmount), baseAmount: money(row.baseAmount),
benefits: money(row.benefits), benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus), bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions), deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable), payable: money(row.payable),
paidAmount: money(paid), paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, 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 ?? '',
})),
}; };
} }
@@ -129,6 +129,29 @@ export class TreasuryController {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income'); 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') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -232,6 +255,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body); 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') @Post('payroll/:id/pay')
payPayroll( payPayroll(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+324 -9
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -359,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
...(from || to ...(from || to
? { ? {
occurredAt: { occurredAt: {
@@ -377,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from }, occurredAt: { lt: from },
}, },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
@@ -530,6 +533,8 @@ export class TreasuryService {
settleAmount?: string | number | null; settleAmount?: string | number | null;
accountId?: string | null; accountId?: string | null;
settledAt?: string | null; settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
}, },
): Promise<FinancialDocumentDto> { ): Promise<FinancialDocumentDto> {
const title = input.title.trim(); const title = input.title.trim();
@@ -560,6 +565,17 @@ export class TreasuryService {
} }
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); 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 doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference( const reference = await this.nextDocumentReference(
tx, tx,
@@ -606,6 +622,45 @@ export class TreasuryService {
}, },
}); });
} }
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({ return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -727,6 +782,7 @@ export class TreasuryService {
amount: Prisma.Decimal; amount: Prisma.Decimal;
settled: Prisma.Decimal; settled: Prisma.Decimal;
remaining: Prisma.Decimal; remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>(); }>();
for (const row of rows) { for (const row of rows) {
if (row.partyKind === 'other') continue; if (row.partyKind === 'other') continue;
@@ -747,13 +803,43 @@ export class TreasuryService {
amount: new Prisma.Decimal(0), amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0), settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0), remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
}; };
current.amount = current.amount.plus(row.amount); current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled); current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining); current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current); 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()] return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining)) .sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({ .map((row) => ({
key: row.key, key: row.key,
@@ -762,9 +848,122 @@ export class TreasuryService {
amount: money(row.amount), amount: money(row.amount),
settledAmount: money(row.settled), settledAmount: money(row.settled),
remaining: money(row.remaining), 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( private async resolveParty(
actor: Actor, actor: Actor,
input: { input: {
@@ -908,16 +1107,51 @@ export class TreasuryService {
}); });
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date(); const now = new Date();
await this.prisma.$transaction([ await this.prisma.$transaction(async (tx) => {
this.prisma.financialMovement.updateMany({ 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 }, where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now }, 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 }, where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, 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({ const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id }, where: { id: doc.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -1092,6 +1326,17 @@ export class TreasuryService {
} }
const bookedIncome = dec(income._sum.amount); const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._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( const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance), (sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -1101,7 +1346,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)), actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)), actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables), receivables: money(receivables),
payables: money(payables), payables: money(payables),
totalBalance: money(totalBalance), totalBalance: money(totalBalance),
@@ -1111,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 }>) { private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements return movements
.filter((row) => !row.voidedAt) .filter((row) => !row.voidedAt)
@@ -1119,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) { private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null }, where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
}); });
return rows.reduce( return rows.reduce(
@@ -1194,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({ 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 }, select: { direction: true, amount: true, kind: true, occurredAt: true },
}); });
let balance = new Prisma.Decimal(0); let balance = new Prisma.Decimal(0);
+15 -3
View File
@@ -104,8 +104,10 @@ export class UsersService {
orgRoleId: dto.orgRoleId, orgRoleId: dto.orgRoleId,
role: dto.role, role: dto.role,
}); });
if (resolved.role === 'owner' && actorRole !== 'owner') { if (resolved.role === 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
} }
const existingUser = await this.prisma.user.findFirst({ where: { mobile } }); 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') { if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
organizationId, organizationId,
@@ -456,7 +462,7 @@ export class UsersService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
@@ -528,6 +534,9 @@ export class UsersService {
} }
} }
if (isActive && !target.isActive) { if (isActive && !target.isActive) {
if (target.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, targetId);
}
await this.billing.assertSeatAvailable(organizationId, targetId); await this.billing.assertSeatAvailable(organizationId, targetId);
} }
await this.prisma.membership.update({ await this.prisma.membership.update({
@@ -558,6 +567,9 @@ export class UsersService {
resolved: { role: UserRole; orgRoleId: string }, resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> { ): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId); await this.billing.assertSeatAvailable(organizationId, userId);
if (resolved.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, userId);
}
const existing = await this.prisma.membership.findFirst({ const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
}); });
+9 -2
View File
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string; placeholder?: string;
clearable?: boolean; clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell'; icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(), }>(),
{ {
mode: 'date', mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false, disabled: false,
clearable: true, clearable: true,
icon: undefined, icon: undefined,
displayText: '',
startView: 'days',
}, },
); );
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
}); });
const display = computed(() => { const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') { if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value); const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return ''; if (!match) return '';
@@ -353,7 +360,7 @@ function place() {
function liftAboveOverlays() { function liftAboveOverlays() {
let z = 120; 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); const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20); if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
} }
@@ -368,7 +375,7 @@ function onWindowChange() {
async function show() { async function show() {
if (props.disabled) return; if (props.disabled) return;
syncDraftFrom(parsed.value); syncDraftFrom(parsed.value);
calView.value = 'days'; calView.value = props.startView;
clockFace.value = 'hour'; clockFace.value = 'hour';
syncViewport(); syncViewport();
liftAboveOverlays(); liftAboveOverlays();
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */ /** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean; asNumber?: boolean;
dir?: 'ltr' | 'rtl'; dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(), }>(),
{ {
required: false, required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2, decimals: 2,
asNumber: false, asNumber: false,
dir: 'ltr', dir: 'ltr',
addonAfter: '',
}, },
); );
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:value="text" :value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums" style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@@ -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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { Select as ASelect } from 'ant-design-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( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string[]; modelValue: string[];
users: PickUser[]; users: PickUser[];
/** انتخاب یک نقش، همهٔ اعضایش را مسئول می‌کند */
roles?: AssigneeGroup[];
/** انتخاب یک تیم، همهٔ اعضایش را مسئول می‌کند */
teams?: AssigneeGroup[];
disabled?: boolean; disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */ /** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number; min?: number;
addLabel?: string; addLabel?: string;
emptyLabel?: string; emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(), }>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' }, {
roles: () => [],
teams: () => [],
disabled: false,
min: 0,
addLabel: 'افزودن',
emptyLabel: 'کسی انتخاب نشده',
selectAll: false,
},
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,32 +39,208 @@ const emit = defineEmits<{
change: [ids: string[]]; change: [ids: string[]];
}>(); }>();
const options = computed(() => const pinned = ref(new Set<string>());
props.users.map((u) => ({ value: u.id, label: u.fullName })),
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 selectValue = computed(() => {
const ids = Array.isArray(value) ? value.map(String) : []; 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) { if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]); emit('update:modelValue', [...props.modelValue]);
return; return;
} }
emit('update:modelValue', ids); 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> </script>
<template> <template>
<a-select <div class="user-multi" :class="{ 'with-all': selectAll }">
mode="multiple" <a-select
allow-clear mode="multiple"
show-search allow-clear
option-filter-prop="label" show-search
:value="modelValue" :value="selectValue"
:options="options" :options="options"
:disabled="disabled" :disabled="disabled"
:placeholder="emptyLabel" :placeholder="emptyLabel"
style="width: 100%" :filter-option="filterOption"
@update:value="onUpdate" :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> </template>
<style scoped>
.user-multi.with-all {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.user-multi.with-all :deep(.ant-select) {
flex: 1 1 auto;
min-width: 0;
}
.user-multi.with-all :deep(.ant-btn) {
flex: 0 0 auto;
}
</style>
@@ -12,6 +12,7 @@ import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import { import {
ACTIVITY_BOARD_COPY, ACTIVITY_BOARD_COPY,
initialsFromTitle, initialsFromTitle,
@@ -41,6 +42,7 @@ const emit = defineEmits<{
stagesChanged: []; stagesChanged: [];
add: [stageId: string]; add: [stageId: string];
error: [message: string]; error: [message: string];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string]; menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string]; toggleSelect: [activityId: string];
bulkDelete: []; bulkDelete: [];
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null); const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green'); const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 }); 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 moving = ref(false);
const stageBusy = ref(false); const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null); const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = { const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined, open: undefined,
@@ -222,12 +264,12 @@ function onMenuActivity(id: string) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id; draggingColumnId.value = null;
overlayDeal.value = deal; // قفل/صف مانده از درخواست قبلی را باز کن
const sid = findStageOf(deal.id); if (moving.value) resetMoveLock();
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green'; dragSessionId = deal.id;
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 }; // setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
@@ -235,12 +277,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
} }
function onDragEnd() { function onDragEnd() {
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
clearDragState();
}, 0);
}
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
@@ -255,17 +327,19 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId];
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId); const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex]; const item = from.items[fromIndex];
if (!item) return; if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!; const toStage = props.stages.find((s) => s.id === toStageId);
if (!toStage) return;
const same = fromStageId === toStageId; const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId); const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return; if (same && index == null) return;
let insertAt = index == null || index < 0 ? 0 : index; // بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1; if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length)); insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return; if (same && insertAt === fromIndex) return;
@@ -287,11 +361,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
moving.value = true; moving.value = true;
armMoveWatchdog();
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, stageId: toStageId,
position: insertAt, 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; const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i)); column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed'); emit('changed');
@@ -304,24 +394,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
window.clearTimeout(moveWatchdog);
moving.value = false; moving.value = false;
} }
} }
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
dropStageId.value = null; const cardId = dragSessionId || draggingId.value;
draggingId.value = null; const columnId = draggingColumnId.value;
overlayDeal.value = null; clearDragState();
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value; // کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
draggingColumnId.value = null; const activityId =
if (fromId) await reorderColumn(fromId, stageId); cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return; return;
} }
if (!raw || moving.value) return;
await moveCard(raw, stageId, index); const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
} }
async function persistOrder(ids: string[]) { async function persistOrder(ids: string[]) {
@@ -404,7 +502,7 @@ async function onAddStage(name: string) {
} }
} }
function openActivity(id: string) { function activityQuery(id: string) {
const query: Record<string, string> = { const query: Record<string, string> = {
from: 'desk', from: 'desk',
nav: crypto.randomUUID(), nav: crypto.randomUUID(),
@@ -424,7 +522,31 @@ function openActivity(id: string) {
if (source.due) query.due = source.due; if (source.due) query.due = source.due;
const columnId = findStageOf(id); const columnId = findStageOf(id);
if (columnId) query.stage = columnId; 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> </script>
@@ -440,23 +562,32 @@ function openActivity(id: string) {
:copy="ACTIVITY_BOARD_COPY" :copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart" @drag-start="onDragStart"
@drag-end="onDragEnd" @drag-end="onDragEnd"
@drag-over="onDragOver" @drag-over="onDragOver"
@drop="onDrop" @drop="onDrop"
@add-deal="emit('add', $event)" @add-deal="emit('add', $event)"
@open-deal="openActivity" @open-deal="openActivity"
@detail-deal="openFullActivity"
@menu-deal="onMenuActivity" @menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage" @rename-stage="onRenameStage"
@delete-stage="onDeleteStage" @delete-stage="onDeleteStage"
@move-stage="onMoveStage" @move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)" @column-drag-start="onColumnDragStart"
@column-drag-end="draggingColumnId = null; dropStageId = null" @column-drag-end="onColumnDragEnd"
@add-stage="onAddStage" @add-stage="onAddStage"
@load-more="loadMore" @load-more="loadMore"
/> />
<ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -17,6 +17,8 @@ const props = defineProps<{
activity: ActivityDto; activity: ActivityDto;
saving?: boolean; saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>; save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="layer" role="presentation"> <div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<div class="backdrop" aria-hidden="true" /> <div class="backdrop" aria-hidden="true" />
<section <section
class="sheet" class="sheet"
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
const props = defineProps<{ const props = defineProps<{
dueAt?: string | null; dueAt?: string | null;
saving?: boolean; saving?: boolean;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
<template> <template>
<Teleport to="body"> <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" /> <button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title"> <section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" /> <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 -36
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { import {
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
@@ -18,6 +18,7 @@ import {
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { userAvatarSrc } from '@/lib/userAvatars'; import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared'; import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -31,6 +32,9 @@ const props = withDefaults(
copy?: PipelineUiCopy; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selected?: boolean; selected?: boolean;
peeking?: boolean;
/** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */
previewing?: boolean;
}>(), }>(),
{ copy: () => DEAL_BOARD_COPY }, { copy: () => DEAL_BOARD_COPY },
); );
@@ -39,6 +43,7 @@ const emit = defineEmits<{
dragStart: [deal: PipelineDeal, event: DragEvent]; dragStart: [deal: PipelineDeal, event: DragEvent];
dragEnd: []; dragEnd: [];
open: [dealId: string]; open: [dealId: string];
detail: [dealId: string];
menu: [dealId: string]; menu: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -47,6 +52,11 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const activityOpen = ref(false);
const highlighted = computed(
() => props.selected || props.peeking || activityOpen.value,
);
const showAmount = computed( const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null, () => !props.copy.hideAmount && props.deal.amount != null,
); );
@@ -74,15 +84,40 @@ const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null, 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(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1, // تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو می‌کند؛ فقط سایه
transform: props.dragging ? 'scale(0.98)' : 'none', opacity: props.dragging ? 0.55 : 1,
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease', transition: 'opacity 120ms ease, box-shadow 120ms ease',
margin: 0, margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer, position: highlighted.value ? 'relative' : undefined,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary, zIndex: highlighted.value ? 2 : undefined,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary, 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> </script>
@@ -124,21 +159,29 @@ const cardStyle = computed(() => ({
</template> </template>
</a-button> </a-button>
<div :style="{ flex: 1, minWidth: 0 }"> <div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text <a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
ellipsis <a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
:style="{ <FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
display: 'block', </a-tooltip>
margin: 0, <a-typography-text
lineHeight: 1.35, ellipsis
cursor: 'pointer', :style="{
fontSize: `${token.fontSizeSM}px`, flex: 1,
fontWeight: 600, minWidth: 0,
}" display: 'block',
:title="deal.title" margin: 0,
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)" lineHeight: 1.35,
> cursor: 'pointer',
{{ deal.title }} fontSize: `${token.fontSizeSM}px`,
</a-typography-text> fontWeight: 600,
}"
:title="deal.title"
@click.stop="onTitleClick"
@dblclick.stop
>
{{ deal.title }}
</a-typography-text>
</a-flex>
<a-typography-text <a-typography-text
v-if="deal.description" v-if="deal.description"
type="secondary" type="secondary"
@@ -198,22 +241,44 @@ const cardStyle = computed(() => ({
{{ deal.date }} {{ deal.date }}
</a-typography-text> </a-typography-text>
</a-space> </a-space>
<a-typography-text <a-space :size="4" align="center" :style="{ flexShrink: 0 }">
v-if="showAmount" <a-typography-text
strong v-if="showAmount"
dir="ltr" strong
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }" dir="ltr"
> :style="{ margin: 0, whiteSpace: 'nowrap' }"
{{ money.number }} >
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }"> {{ money.number }}
{{ money.unit }} <a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text> </a-typography-text>
</a-typography-text> <a-button
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`"> v-if="copy.quickView && !selectMode"
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }"> type="text"
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" /> :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> </span>
</a-tooltip> </a-space>
</a-flex> </a-flex>
</a-flex> </a-flex>
</a-card> </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; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selectedIds?: Set<string>; 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<{ const emit = defineEmits<{
@@ -32,6 +34,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number]; drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string]; addDeal: [stageId: string];
openDeal: [dealId: string]; openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string]; menuDeal: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -109,6 +112,8 @@ function submitAdd() {
:copy="copy" :copy="copy"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="previewId"
:is-first="index === 0" :is-first="index === 0"
:is-last="index === stages.length - 1" :is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@@ -117,6 +122,7 @@ function submitAdd() {
@drop="(id, e, index) => emit('drop', id, e, index)" @drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)" @add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)" @open-deal="emit('openDeal', $event)"
@detail-deal="emit('detailDeal', $event)"
@menu-deal="emit('menuDeal', $event)" @menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@@ -26,8 +26,10 @@ const props = withDefaults(
copy?: PipelineUiCopy; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selectedIds?: Set<string>; 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<{ const emit = defineEmits<{
@@ -37,6 +39,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number]; drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string]; addDeal: [stageId: string];
openDeal: [dealId: string]; openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string]; menuDeal: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
:copy="copy" :copy="copy"
:select-mode="selectMode" :select-mode="selectMode"
:selected="selectedIds.has(deal.id)" :selected="selectedIds.has(deal.id)"
:peeking="peekId === deal.id"
:previewing="previewId === deal.id"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')" @drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)" @open="emit('openDeal', $event)"
@detail="emit('detailDeal', $event)"
@menu="emit('menuDeal', $event)" @menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue'; import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared'; import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => { const links = computed(() => {
const role = auth.user?.role; const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance'; 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) { function onChange(value: string | number) {
const next = String(value); const next = String(value);
if (next && next !== route.path) void router.push(next); if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template> <template>
<div <div
ref="scroller"
:style="{ :style="{
width: '100%', width: '100%',
overflowX: 'auto', overflowX: 'auto',
@@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue'; import {
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format'; import { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{ const props = defineProps<{
rows: TreasuryPartyBalanceDto[]; rows: TreasuryPartyBalanceDto[];
settledLabel: string; settledLabel: string;
}>(); }>();
const isMobile = useTreasuryMobile();
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const kindLabel: Record<string, string> = { const kindLabel: Record<string, string> = {
person: 'مشتری', person: 'مشتری',
company: 'شرکت', company: 'شرکت',
@@ -39,10 +49,43 @@ const columns = computed(() => [
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining), customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
}, },
]); ]);
function kindOf(record: TreasuryPartyBalanceDto) {
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
}
</script> </script>
<template> <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 <a-table
v-else
:columns="columns" :columns="columns"
:data-source="rows" :data-source="rows"
:pagination="false" :pagination="false"
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup; const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind'); const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId'); const partyId = defineModel<string | undefined>('partyId');
@@ -156,12 +161,12 @@ onMounted(() => {
:filter-option="false" :filter-option="false"
:loading="loading" :loading="loading"
:value="selectValue" :value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر" :placeholder="placeholder"
style="width: 100%" style="width: 100%"
@search="onSearch" @search="onSearch"
@change="onChange" @change="onChange"
> >
<a-select-option value="other" label="سایر">سایر</a-select-option> <a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان"> <a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option <a-select-option
v-for="row in peopleOptions" v-for="row in peopleOptions"
+12 -6
View File
@@ -21,6 +21,7 @@ import type {
import { canManageSettings, canViewAllRecords } from '@/lib/roles'; import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { import {
canOpenPath, canOpenPath,
firstAllowedFinancePath,
hasSectionPermission, hasSectionPermission,
sectionPermissionForPath, sectionPermissionForPath,
} from '@/lib/section-permissions'; } from '@/lib/section-permissions';
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
}) { }) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false; if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? []; 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')) { 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 section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section const hasSection = section
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null; if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value); const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null; 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 ( if (
hub.key === 'customers' && hub.key === 'customers' &&
isMobileNav.value && 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;
}
+12 -2
View File
@@ -20,6 +20,8 @@ export type PipelineDeal = {
assigneeNames?: string[]; assigneeNames?: string[];
ownerId?: string | null; ownerId?: string | null;
assigneeIds?: string[]; assigneeIds?: string[];
personId?: string | null;
companyId?: string | null;
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */ /** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>; searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */ /** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
@@ -34,6 +36,10 @@ export type PipelineUiCopy = {
itemMenuAria: string; itemMenuAria: string;
listColTitle: string; listColTitle: string;
hideAmount?: boolean; hideAmount?: boolean;
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه به‌جای ورود مستقیم به جزئیات */
quickView?: boolean;
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
quickActivity?: boolean;
}; };
export const DEAL_BOARD_COPY: PipelineUiCopy = { export const DEAL_BOARD_COPY: PipelineUiCopy = {
@@ -43,6 +49,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
addItemAria: 'افزودن معامله به این مرحله', addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله', itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله', listColTitle: 'معامله',
quickActivity: true,
}; };
export const PROJECT_BOARD_COPY: PipelineUiCopy = { export const PROJECT_BOARD_COPY: PipelineUiCopy = {
@@ -63,6 +70,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemMenuAria: 'گزینه‌های فعالیت', itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت', listColTitle: 'فعالیت',
hideAmount: true, hideAmount: true,
quickView: true,
}; };
export type PipelineStage = { export type PipelineStage = {
@@ -182,8 +190,8 @@ export function mapBoardToStages(
expectedCloseAt: string | null; expectedCloseAt: string | null;
createdAt: string; createdAt: string;
promotedFromPriorAt: string | null; promotedFromPriorAt: string | null;
person: { fullName: string } | null; person: { id?: string; fullName: string } | null;
company: { name: string } | null; company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null; owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>; assignees?: Array<{ id: string; fullName: string }>;
}>; }>;
@@ -236,6 +244,8 @@ export function mapBoardToStages(
assigneeNames, assigneeNames,
ownerId: deal.owner?.id ?? null, ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id), assigneeIds: (deal.assignees ?? []).map((a) => a.id),
personId: deal.person?.id ?? null,
companyId: deal.company?.id ?? null,
searchFields: [ searchFields: [
deal.notes, deal.notes,
deal.company?.name, deal.company?.name,
+24
View File
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
return treasurySectionAllowed(role, permissions, section); 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( export function canOpenPath(
role: UserRole | string | null | undefined, role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined, permissions: readonly string[] | null | undefined,
+12 -10
View File
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared'; import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth'; import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions'; import {
canOpenFinancePath,
canOpenPath,
firstAllowedFinancePath,
sectionPermissionForPath,
} from '@/lib/section-permissions';
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -330,7 +335,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد', treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.', treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
}, },
}, },
{ {
@@ -350,7 +356,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی', treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
}, },
}, },
{ {
@@ -681,13 +688,8 @@ router.beforeEach((to) => {
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) { if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true; return true;
} }
const exactHome = to.path === '/finance' || to.path === '/finance/'; const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
if ( if (next && next !== to.path) return { path: next };
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
} }
return { name: 'home' }; return { name: 'home' };
} }
+6
View File
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) void hydrate(); if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return { return {
token, token,
user, user,
+98 -41
View File
@@ -52,7 +52,10 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles'; 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 { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -201,6 +204,7 @@ const page = computed(() => {
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const notice = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null); const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
@@ -445,23 +449,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
return query; return query;
} }
function activityRoute(id: string, edit = false, columnStageId?: string) { function activityRoute(id: string, 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;
}
return { return {
name: 'activity-edit' as const, name: 'activity-detail' as const,
params: { id }, params: { id },
query, query: deskDetailQuery(columnStageId),
}; };
} }
@@ -837,6 +829,30 @@ function onFacetDone() {
openCol.value = null; 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) { async function complete(id: string) {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
@@ -913,10 +929,27 @@ function clearSelect() {
selectedIds.value = new Set(); 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) { function askDeleteActivity(id: string, title?: string) {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null; boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
deleteSingleTitle.value = title?.trim() || activityTitle(id); deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id]; deleteActivityIds.value = [id];
} }
@@ -926,8 +959,24 @@ function askBulkDelete() {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
boardMenuActivity.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 = ''; deleteSingleTitle.value = '';
deleteActivityIds.value = [...selectedIds.value]; deleteActivityIds.value = allowed;
} }
async function confirmDeleteActivities() { async function confirmDeleteActivities() {
@@ -937,16 +986,24 @@ async function confirmDeleteActivities() {
error.value = ''; error.value = '';
try { try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`))); 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 = []; deleteActivityIds.value = [];
clearSelect(); clearSelect();
kanbanReload.value += 1; kanbanReload.value += 1;
await refreshAll(); await refreshAll();
if (failed) { if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value = error.value =
failed === ids.length failed.length === ids.length
? 'حذف فعالیت‌ها ناموفق بود.' ? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: `${toFaDigits(failed)} مورد حذف نشد.`; : detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
} }
} finally { } finally {
activityDeleting.value = false; activityDeleting.value = false;
@@ -1503,6 +1560,7 @@ onUnmounted(() => {
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-if="notice" class="ok">{{ notice }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p> <p v-if="importMsg" class="ok">{{ importMsg }}</p>
<div v-if="selectMode" class="select-bar"> <div v-if="selectMode" class="select-bar">
@@ -1532,6 +1590,7 @@ onUnmounted(() => {
@stages-changed="onStagesChanged" @stages-changed="onStagesChanged"
@add="openAddInStage" @add="openAddInStage"
@error="(msg: string) => (error = msg)" @error="(msg: string) => (error = msg)"
@notice="(msg: string) => (notice = msg)"
@menu-deal="onBoardMenu" @menu-deal="onBoardMenu"
@toggle-select="toggleSelect" @toggle-select="toggleSelect"
@bulk-delete="askBulkDelete" @bulk-delete="askBulkDelete"
@@ -1749,16 +1808,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)"> <RouterLink :to="activityRoute(item.id)">
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)"> <button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری انتقال به کار جاری
</button> </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>
<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> </button>
</UiDropdownMenu> </UiDropdownMenu>
@@ -1839,16 +1900,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)"> <RouterLink :to="activityRoute(item.id)">
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)"> <button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری انتقال به کار جاری
</button> </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>
<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> </button>
</UiDropdownMenu> </UiDropdownMenu>
@@ -1895,18 +1958,11 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button> <button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink <RouterLink
role="menuitem" role="menuitem"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)" :to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''" @click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
> >
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button <button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)" v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button" type="button"
@@ -1916,6 +1972,7 @@ onUnmounted(() => {
انتقال به کار جاری انتقال به کار جاری
</button> </button>
<button <button
v-if="canDeleteActivity(boardMenuActivity)"
type="button" type="button"
role="menuitem" role="menuitem"
class="danger" 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 leadId = ref('');
const dealId = ref(''); const dealId = ref('');
const assigneeIds = ref<string[]>([]); const assigneeIds = ref<string[]>([]);
const completionMode = ref<'any' | 'all'>('any');
const editPolicy = ref<'creator' | 'assignees'>('creator');
const isDone = ref(false); const isDone = ref(false);
const needsNote = ref(false); const needsNote = ref(false);
const reminderAt = ref(''); const reminderAt = ref('');
@@ -440,6 +442,8 @@ function resetForm() {
personId.value = ''; personId.value = '';
dealId.value = ''; dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : []; assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
completionMode.value = 'any';
editPolicy.value = 'creator';
pickedRoleIds.value = []; pickedRoleIds.value = [];
pickedTeamIds.value = []; pickedTeamIds.value = [];
isDone.value = false; isDone.value = false;
@@ -528,6 +532,8 @@ async function load() {
: auth.user?.id : auth.user?.id
? [auth.user.id] ? [auth.user.id]
: []; : [];
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
refreshPickedGroups(); refreshPickedGroups();
isDone.value = data.status === 'done'; isDone.value = data.status === 'done';
await loadSavedPhotos(data); await loadSavedPhotos(data);
@@ -541,6 +547,16 @@ async function load() {
personId.value = incomingPerson; personId.value = incomingPerson;
} }
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : ''; 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 = const incomingDef =
typeof route.query.typeDefId === 'string' typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId) ? activityTypes.byId.value.get(route.query.typeDefId)
@@ -605,6 +621,8 @@ function payload(): CreateActivityRequest {
leadId: leadId.value || null, leadId: leadId.value || null,
assigneeIds: [...assigneeIds.value], assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null, 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; savedId = data.id;
createdActivityId.value = data.id; createdActivityId.value = data.id;
if (isDone.value) { if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`); await api.patch(`/activities/${savedId}/complete`, undefined, {
params: { force: '1' },
});
} }
} else { } else {
await api.patch(`/activities/${savedId}`, { await api.patch(`/activities/${savedId}`, {
@@ -1269,6 +1289,20 @@ watch([id, isNew], () => void load(), { immediate: true });
</li> </li>
</ul> </ul>
</div> </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"> <small class="hint">
{{ {{
canAssignOthers canAssignOthers
@@ -1302,6 +1336,29 @@ watch([id, isNew], () => void load(), { immediate: true });
</div> </div>
</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="card">
<div class="field"> <div class="field">
<div class="label-row"> <div class="label-row">
@@ -1799,6 +1856,66 @@ watch([id, isNew], () => void load(), { immediate: true });
font-size: 0.75rem; 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 { .assignee-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+63
View File
@@ -18,6 +18,7 @@ import {
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
InputNumber as AInputNumber, InputNumber as AInputNumber,
Modal,
Modal as AModal, Modal as AModal,
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
@@ -44,9 +45,11 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money'; import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue'; import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedActivitiesPanel from '@/components/activities/RelatedActivitiesPanel.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue'; import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue'; import PersonFormView from '@/views/PersonFormView.vue';
import type { import type {
@@ -104,6 +107,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const stageMoving = ref(false);
const error = ref(''); const error = ref('');
const showOthers = ref(true); const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']); const othersKeys = ref<string[]>(['others']);
@@ -197,6 +201,43 @@ const pipelineSelectOptions = computed(() =>
const stageSelectOptions = computed(() => const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })), 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(() => const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })), products.value.map((p) => ({ value: p.id, label: p.name })),
); );
@@ -665,6 +706,20 @@ onMounted(load);
</a-space> </a-space>
</a-flex> </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-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit"> <a-form layout="vertical" @finish="submit">
@@ -960,6 +1015,14 @@ onMounted(load);
</a-form-item> </a-form-item>
</a-card> </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 <RelatedTasksPanel
v-if="!isNew && id" v-if="!isNew && id"
:style="{ marginTop: '16px' }" :style="{ marginTop: '16px' }"
File diff suppressed because it is too large Load Diff
+34 -45
View File
@@ -10,6 +10,7 @@ import NoteCategoriesManager from '@/components/notes/NoteCategoriesManager.vue'
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { appendSpokenText } from '@/lib/spoken-text'; import { appendSpokenText } from '@/lib/spoken-text';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -721,7 +722,7 @@ function syncMobileFlag() {
function onDocClick(ev: MouseEvent) { function onDocClick(ev: MouseEvent) {
const t = ev.target as HTMLElement | null; const t = ev.target as HTMLElement | null;
if (!t?.closest?.('.card-menu')) menuOpenId.value = null; if (!t?.closest?.('.card-menu, .ui-dd-panel')) menuOpenId.value = null;
} }
watch(typeFilter, () => { watch(typeFilter, () => {
@@ -962,27 +963,34 @@ onBeforeUnmount(() => {
</svg> </svg>
</span> </span>
<div class="card-menu" @click.stop> <div class="card-menu" @click.stop>
<button <UiDropdownMenu
type="button" :open="menuOpenId === note.id"
class="icon-btn menu-btn" align="end"
@click="menuOpenId = menuOpenId === note.id ? null : note.id" @update:open="(v) => (menuOpenId = v ? note.id : null)"
> >
<UiIcon name="dots-v" :size="18" /> <template #trigger="{ toggle }">
</button> <button
<div v-if="menuOpenId === note.id" class="menu-pop"> type="button"
<button type="button" @click="openEditor('default', note)">ویرایش</button> class="icon-btn menu-btn"
<button type="button" @click="togglePin(note.id)"> aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
<button type="button" @click.stop="openEditor('default', note)">ویرایش</button>
<button type="button" @click.stop="togglePin(note.id)">
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }} {{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
</button> </button>
<button <button
v-if="(note.revisionCount ?? 0) > 0" v-if="(note.revisionCount ?? 0) > 0"
type="button" type="button"
@click="openHistory(note)" @click.stop="openHistory(note)"
> >
تاریخچه ویرایش تاریخچه ویرایش
</button> </button>
<button type="button" class="danger" @click="removeNote(note.id)">حذف</button> <button type="button" class="danger" @click.stop="removeNote(note.id)">حذف</button>
</div> </UiDropdownMenu>
</div> </div>
</div> </div>
@@ -1893,42 +1901,16 @@ onBeforeUnmount(() => {
position: relative; position: relative;
margin-inline-start: auto; margin-inline-start: auto;
} }
.menu-pop {
position: absolute;
inset-inline-end: 0;
top: 100%;
z-index: 5;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 140px;
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.menu-pop button {
border: 0;
background: transparent;
text-align: start;
padding: 0.45rem 0.65rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
color: #334155;
}
.menu-pop button:hover {
background: #f8fafc;
}
.menu-pop .danger {
color: #dc2626;
}
.card-title { .card-title {
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 700; font-weight: 700;
line-height: 1.35;
color: #0f172a; color: #0f172a;
flex-shrink: 0;
min-width: 0;
overflow-wrap: anywhere;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -1940,7 +1922,7 @@ onBeforeUnmount(() => {
color: #334155; color: #334155;
font-size: 0.82rem; font-size: 0.82rem;
line-height: 1.45; line-height: 1.45;
flex: 1; flex: 1 1 2.9em;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
@@ -1975,13 +1957,15 @@ onBeforeUnmount(() => {
margin-top: 0.45rem; margin-top: 0.45rem;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex: 0 1 72px;
min-height: 0;
max-height: 72px; max-height: 72px;
} }
.card-thumb img { .card-thumb img {
display: block; display: block;
width: 100%; width: 100%;
height: 72px; height: 72px;
max-height: 100%;
object-fit: cover; object-fit: cover;
} }
@@ -2198,11 +2182,16 @@ onBeforeUnmount(() => {
} }
.title-input { .title-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0; border: 0;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;
padding: 0.35rem 0; padding: 0.35rem 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
line-height: 1.4;
color: #0f172a;
outline: none; outline: none;
background: transparent; background: transparent;
} }
+41 -1
View File
@@ -97,6 +97,9 @@ const website = ref('');
const instagram = ref(''); const instagram = ref('');
const birthDate = ref(''); const birthDate = ref('');
const referrer = ref(''); const referrer = ref('');
const nationalId = ref('');
const city = ref('');
const rasteh = ref('');
const addresses = ref<string[]>(['']); const addresses = ref<string[]>(['']);
const notes = ref(''); const notes = ref('');
const tagsRaw = ref(''); const tagsRaw = ref('');
@@ -283,6 +286,16 @@ function fill(p: PersonDto) {
mood.value = p.mood || 'newcomer'; mood.value = p.mood || 'newcomer';
groupIds.value = (p.groups ?? []).map((g) => g.id); groupIds.value = (p.groups ?? []).map((g) => g.id);
customFields.value = { ...(p.customFields ?? {}) }; customFields.value = { ...(p.customFields ?? {}) };
const cf = customFields.value;
nationalId.value =
typeof cf.nationalId === 'string'
? cf.nationalId
: typeof cf.nationalId === 'number'
? String(cf.nationalId)
: '';
city.value = typeof cf.city === 'string' ? cf.city : '';
rasteh.value =
typeof cf.rasteh === 'string' || typeof cf.rasteh === 'number' ? String(cf.rasteh) : '';
loyaltyPoints.value = p.loyaltyPoints ?? 0; loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? ''; loyaltyTierName.value = p.loyaltyTier?.name ?? '';
} }
@@ -344,6 +357,18 @@ function parseTags(raw: string): string[] {
} }
function payload(): CreatePersonRequest { function payload(): CreatePersonRequest {
const fields: Record<string, unknown> = { ...customFields.value };
const nid = nationalId.value.trim();
if (nid) fields.nationalId = nid;
else delete fields.nationalId;
const cityVal = city.value.trim();
if (cityVal) fields.city = cityVal;
else delete fields.city;
const rastehRaw = rasteh.value.trim();
if (rastehRaw) {
const n = Number(rastehRaw);
fields.rasteh = Number.isFinite(n) && String(n) === rastehRaw ? n : rastehRaw;
} else delete fields.rasteh;
return { return {
firstName: firstName.value, firstName: firstName.value,
lastName: lastName.value, lastName: lastName.value,
@@ -363,7 +388,7 @@ function payload(): CreatePersonRequest {
companyId: companyId.value || null, companyId: companyId.value || null,
mood: mood.value, mood: mood.value,
groupIds: groupIds.value, groupIds: groupIds.value,
customFields: customFields.value, customFields: fields,
}; };
} }
@@ -545,6 +570,21 @@ onMounted(load);
<a-input v-model:value="referrer" placeholder="نام معرف" /> <a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12">
<a-form-item label="کد ملی">
<a-input v-model:value="nationalId" dir="ltr" placeholder="کد ملی (ته‌حساب)" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شهر">
<a-input v-model:value="city" placeholder="شهر" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="رسته">
<a-input v-model:value="rasteh" dir="ltr" placeholder="رسته حساب ته‌حساب" />
</a-form-item>
</a-col>
</a-row> </a-row>
<a-divider orientation="right">آدرس‌ها</a-divider> <a-divider orientation="right">آدرس‌ها</a-divider>
+36
View File
@@ -183,6 +183,18 @@ const externalCode = computed(() => {
} }
return ''; return '';
}); });
function customFieldText(...keys: string[]) {
const fields = person.value?.customFields ?? {};
for (const key of keys) {
const raw = fields[key];
if (typeof raw === 'string' && raw.trim()) return raw.trim();
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
}
return '';
}
const nationalIdLabel = computed(() => customFieldText('nationalId', 'CodeMelli', 'codeMeli'));
const cityLabel = computed(() => customFieldText('city', 'City'));
const rastehLabel = computed(() => customFieldText('rasteh', 'Rasteh'));
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
manual: 'دستی', manual: 'دستی',
tahesab: 'ته‌حساب', tahesab: 'ته‌حساب',
@@ -444,6 +456,9 @@ const infoEmpty = computed(() => {
!p.instagram && !p.instagram &&
!p.birthDate && !p.birthDate &&
!p.referrer && !p.referrer &&
!nationalIdLabel.value &&
!cityLabel.value &&
!rastehLabel.value &&
!(p.addresses?.length) && !(p.addresses?.length) &&
!p.company && !p.company &&
!externalCode.value && !externalCode.value &&
@@ -794,6 +809,18 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>معرف</dt> <dt>معرف</dt>
<dd>{{ person.referrer }}</dd> <dd>{{ person.referrer }}</dd>
</div> </div>
<div v-if="nationalIdLabel" class="fact">
<dt>کد ملی</dt>
<dd dir="ltr">{{ toFaDigits(nationalIdLabel) }}</dd>
</div>
<div v-if="cityLabel" class="fact">
<dt>شهر</dt>
<dd>{{ cityLabel }}</dd>
</div>
<div v-if="rastehLabel" class="fact">
<dt>رسته</dt>
<dd dir="ltr">{{ toFaDigits(rastehLabel) }}</dd>
</div>
<div v-if="person.website" class="fact"> <div v-if="person.website" class="fact">
<dt>وب‌سایت</dt> <dt>وب‌سایت</dt>
<dd dir="ltr">{{ person.website }}</dd> <dd dir="ltr">{{ person.website }}</dd>
@@ -1119,6 +1146,15 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div><dt>وب‌سایت</dt><dd>{{ person.website || '—' }}</dd></div> <div><dt>وب‌سایت</dt><dd>{{ person.website || '—' }}</dd></div>
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div> <div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div> <div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
<div>
<dt>کد ملی</dt>
<dd dir="ltr">{{ nationalIdLabel ? toFaDigits(nationalIdLabel) : '—' }}</dd>
</div>
<div><dt>شهر</dt><dd>{{ cityLabel || '—' }}</dd></div>
<div>
<dt>رسته</dt>
<dd dir="ltr">{{ rastehLabel ? toFaDigits(rastehLabel) : '—' }}</dd>
</div>
<div class="wide"> <div class="wide">
<dt>آدرس‌ها</dt> <dt>آدرس‌ها</dt>
<dd> <dd>
+75 -42
View File
@@ -108,6 +108,7 @@ const overlayAccent = ref<PipelineStage['color']>('green');
const overlayPos = ref({ x: 0, y: 0 }); const overlayPos = ref({ x: 0, y: 0 });
const menuOpenId = ref<string | null>(null); const menuOpenId = ref<string | null>(null);
const moving = ref(false); const moving = ref(false);
let dragSessionId: string | null = null;
const pipelineOptions = computed(() => const pipelineOptions = computed(() =>
pipelines.value.map((p) => ({ pipelines.value.map((p) => ({
@@ -177,7 +178,9 @@ function findAccent(dealId: string): PipelineStage['color'] {
} }
async function loadPipelines() { async function loadPipelines() {
const { data } = await api.get<PipelineDto[]>('/pipelines'); const { data } = await api.get<PipelineDto[]>('/pipelines', {
params: { forLens: '1' },
});
pipelines.value = data; pipelines.value = data;
} }
@@ -186,16 +189,23 @@ async function load() {
error.value = ''; error.value = '';
try { try {
await loadPipelines(); await loadPipelines();
const pipelineId = const requested =
typeof route.query.pipelineId === 'string' typeof route.query.pipelineId === 'string' ? route.query.pipelineId : '';
? route.query.pipelineId const pipelineId = pipelines.value.some((item) => item.id === requested)
: undefined; ? requested
: pipelines.value[0]?.id;
if (!pipelineId) {
boardPipelineId.value = '';
stages.value = [];
if (requested) await router.replace({ name: 'pipeline', query: {} });
return;
}
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', { const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pipelineId ? { pipelineId } : undefined, params: { pipelineId },
}); });
boardPipelineId.value = data.pipeline.id; boardPipelineId.value = data.pipeline.id;
stages.value = mapBoardToStages(data, formatDealDate); stages.value = mapBoardToStages(data, formatDealDate);
if (!pipelineId || pipelineId !== data.pipeline.id) { if (requested !== data.pipeline.id) {
await router.replace({ await router.replace({
name: 'pipeline', name: 'pipeline',
query: { pipelineId: data.pipeline.id }, query: { pipelineId: data.pipeline.id },
@@ -235,13 +245,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id; draggingColumnId.value = null;
overlayDeal.value = deal; if (moving.value) moving.value = false;
overlayAccent.value = findAccent(deal.id); dragSessionId = deal.id;
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move'; event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -250,12 +256,29 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
// DOM را بعد از dragstart عوض کن تا کروم درگ را لغو نکند
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
} }
function onDragEnd() { function onDragEnd() {
draggingId.value = null; const endedId = dragSessionId ?? draggingId.value;
dropStageId.value = null; window.setTimeout(() => {
overlayDeal.value = null; if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}, 0);
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
@@ -295,37 +318,45 @@ async function applyUpdateDealStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const raw = const transfer = event.dataTransfer?.getData('text/plain') || '';
event.dataTransfer?.getData('text/plain') || const draggedCardId = dragSessionId || draggingId.value;
draggingColumnId.value || const columnId = draggingColumnId.value;
draggingId.value ||
'';
dropStageId.value = null; dropStageId.value = null;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) { draggingColumnId.value = null;
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null; // کارت بر payload ستون اولویت دارد
if (fromId) await reorderColumn(fromId, stageId); const dealId =
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (dealId) {
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
// اگر جابه‌جایی قبلی هنوز در جریان است، این یکی را صف نکن — فقط رد نکن بی‌صدا با early return قدیمی
if (moving.value) {
moving.value = false;
}
await applyUpdateDealStage(dealId, stageId, index);
return; return;
} }
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId)); const isStageDrag = transfer.startsWith('stage:');
if (current?.id === stageId && index == null) return; if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
const next = updateDealStage(stages.value, dealId, stageId, index); if (fromId) await reorderColumn(fromId, stageId);
const unchanged = next.every((stage, stageIndex) => { }
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
} }
function apiMessage(e: unknown, fallback: string) { function apiMessage(e: unknown, fallback: string) {
@@ -342,6 +373,8 @@ function onAddDeal(stageId: string) {
} }
function onColumnDragStart(stageId: string) { function onColumnDragStart(stageId: string) {
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = stageId; draggingColumnId.value = stageId;
} }
+29 -13
View File
@@ -411,25 +411,25 @@ onMounted(load);
</td> </td>
<td class="ops" @click.stop> <td class="ops" @click.stop>
<UiDropdownMenu <UiDropdownMenu
:open="menuId === p.id" :open="menuId === `d:${p.id}`"
align="end" align="end"
@update:open="(v) => (menuId = v ? p.id : null)" @update:open="(v) => (menuId = v ? `d:${p.id}` : null)"
> >
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<button <button
type="button" type="button"
class="icon-btn" class="more"
aria-label="عملیات" aria-label="عملیات"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="dots" :size="16" /> <UiIcon name="dots-v" :size="16" />
</button> </button>
</template> </template>
<button type="button" @click="goEdit(p.id)">ویرایش</button> <button type="button" @click.stop="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)"> <button type="button" @click.stop="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} {{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> <button type="button" class="danger" @click.stop="removeProduct(p)">حذف</button>
</UiDropdownMenu> </UiDropdownMenu>
</td> </td>
</tr> </tr>
@@ -460,25 +460,25 @@ onMounted(load);
</div> </div>
<div class="mob-side"> <div class="mob-side">
<UiDropdownMenu <UiDropdownMenu
:open="menuId === p.id" :open="menuId === `m:${p.id}`"
align="end" align="end"
@update:open="(v) => (menuId = v ? p.id : null)" @update:open="(v) => (menuId = v ? `m:${p.id}` : null)"
> >
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<button <button
type="button" type="button"
class="icon-btn" class="more"
aria-label="عملیات" aria-label="عملیات"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="dots-v" :size="16" /> <UiIcon name="dots-v" :size="16" />
</button> </button>
</template> </template>
<button type="button" @click="goEdit(p.id)">ویرایش</button> <button type="button" @click.stop="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)"> <button type="button" @click.stop="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} {{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> <button type="button" class="danger" @click.stop="removeProduct(p)">حذف</button>
</UiDropdownMenu> </UiDropdownMenu>
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot> <UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
{{ p.isActive ? 'فعال' : 'غیرفعال' }} {{ p.isActive ? 'فعال' : 'غیرفعال' }}
@@ -680,6 +680,22 @@ tr:last-child td {
.ops { .ops {
width: 64px; width: 64px;
} }
.more {
width: 32px;
height: 32px;
border: 0;
background: none;
color: #94a3b8;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.more:hover {
background: var(--accent-soft);
color: var(--accent);
}
.mob-list { .mob-list {
display: none; display: none;
gap: 0.65rem; gap: 0.65rem;
+47 -29
View File
@@ -56,6 +56,7 @@ const busy = ref(false);
const importMsg = ref(''); const importMsg = ref('');
const draggingId = ref<string | null>(null); const draggingId = ref<string | null>(null);
let dragSessionId: string | null = null;
const draggingColumnId = ref<string | null>(null); const draggingColumnId = ref<string | null>(null);
const dropStageId = ref<string | null>(null); const dropStageId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null); const overlayDeal = ref<PipelineDeal | null>(null);
@@ -177,13 +178,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id; draggingColumnId.value = null;
overlayDeal.value = deal; if (moving.value) moving.value = false;
overlayAccent.value = findAccent(deal.id); dragSessionId = deal.id;
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move'; event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -192,12 +189,28 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
} }
function onDragEnd() { function onDragEnd() {
draggingId.value = null; const endedId = dragSessionId ?? draggingId.value;
dropStageId.value = null; window.setTimeout(() => {
overlayDeal.value = null; if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}, 0);
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
@@ -237,30 +250,33 @@ async function applyUpdateCardStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const raw = const transfer = event.dataTransfer?.getData('text/plain') || '';
event.dataTransfer?.getData('text/plain') || const draggedCardId = dragSessionId || draggingId.value;
draggingColumnId.value || const columnId = draggingColumnId.value;
draggingId.value ||
'';
dropStageId.value = null; dropStageId.value = null;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) { draggingColumnId.value = null;
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null; const cardId =
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (cardId) {
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
if (moving.value) moving.value = false;
await applyUpdateCardStage(cardId, stageId, index);
return;
}
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId); if (fromId) await reorderColumn(fromId, stageId);
return;
} }
const cardId = raw;
if (!cardId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
await applyUpdateCardStage(cardId, stageId, index);
} }
function onAddCard(stageId: string) { function onAddCard(stageId: string) {
@@ -268,6 +284,8 @@ function onAddCard(stageId: string) {
} }
function onColumnDragStart(stageId: string) { function onColumnDragStart(stageId: string) {
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = stageId; draggingColumnId.value = stageId;
} }
+120 -12
View File
@@ -64,6 +64,9 @@ type SyncResult = {
alreadyLinked?: boolean; alreadyLinked?: boolean;
personId?: string | null; personId?: string | null;
personName?: string | null; personName?: string | null;
duplicateOfCode?: number | null;
duplicatePersonId?: string | null;
duplicatePersonName?: string | null;
}; };
const syncResults = ref<SyncResult[]>([]); const syncResults = ref<SyncResult[]>([]);
/** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */ /** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */
@@ -145,9 +148,96 @@ async function saveAutoSync() {
autoSyncSaving.value = false; autoSyncSaving.value = false;
} }
} }
/** همهٔ مشتریان ته‌حساب را می‌خواند: تازه‌ها وارد، لینک‌شده‌ها پروفایل‌شان به‌روز می‌شود */
async function syncCustomersNow() {
if (!sync.connectionId) {
error.value = 'اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = null;
importOverlay.message =
'در حال همگام‌سازی همهٔ مشتریان… نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه به‌روز می‌شود.';
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{
imported: number;
updated: number;
duplicate: number;
failed: number;
summary: string;
error: string | null;
}>('/integrations/tahesab/sync/customers-now', {
connectionId: sync.connectionId,
});
ok.value = data.summary;
if (data.error) error.value = data.error;
await load();
} catch (e) {
error.value = apiErr(e, 'همگام‌سازی مشتریان ناموفق بود');
} finally {
syncBusy.value = false;
importOverlay.open = false;
}
}
/** همان قاعدهٔ سرور: موبایل ایران 09… است، بقیهٔ شماره‌ها فقط رقم‌اند. */
function customerPhoneKey(value?: string | null): string {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
let digits = String(value ?? '')
.replace(/[۰-۹٠-٩]/g, (ch) => {
const faIndex = fa.indexOf(ch);
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
})
.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0098') && digits.length >= 14) digits = digits.slice(4);
else if (digits.startsWith('98') && digits.length >= 12) digits = digits.slice(2);
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
return digits;
}
const linkedPhoneOwners = computed(() => {
const owners = new Map<string, { code: number; personId: string | null; personName: string | null }>();
for (const row of syncResults.value) {
if (!row.alreadyLinked || row.moshtariCode == null) continue;
const key = customerPhoneKey(row.tel);
if (key && !owners.has(key)) {
owners.set(key, {
code: row.moshtariCode,
personId: row.personId ?? null,
personName: row.personName ?? null,
});
}
}
return owners;
});
function duplicateOwner(row: SyncResult) {
if (row.alreadyLinked || row.moshtariCode == null) return null;
if (row.duplicateOfCode != null) {
return {
code: row.duplicateOfCode,
personId: row.duplicatePersonId ?? null,
personName: row.duplicatePersonName ?? null,
};
}
const key = customerPhoneKey(row.tel);
if (!key) return null;
const owner = linkedPhoneOwners.value.get(key);
if (!owner || owner.code === row.moshtariCode) return null;
return owner;
}
const duplicateCustomerCount = computed(
() => syncResults.value.filter((row) => duplicateOwner(row)).length,
);
const syncVisibleResults = computed(() => const syncVisibleResults = computed(() =>
syncOnlyNew.value syncOnlyNew.value
? syncResults.value.filter((r) => !r.alreadyLinked) ? syncResults.value.filter((row) => !row.alreadyLinked && !duplicateOwner(row))
: syncResults.value, : syncResults.value,
); );
const syncTableTotalPages = computed(() => const syncTableTotalPages = computed(() =>
@@ -184,7 +274,7 @@ const productCategoryLabel = ref('کارساخته');
function pendingImportCodes() { function pendingImportCodes() {
return syncResults.value return syncResults.value
.filter((r) => r.moshtariCode != null && !r.alreadyLinked) .filter((r) => r.moshtariCode != null && !r.alreadyLinked && !duplicateOwner(r))
.map((r) => r.moshtariCode as number); .map((r) => r.moshtariCode as number);
} }
@@ -458,10 +548,12 @@ async function searchTahesab(mode: 'range' | 'filter') {
ok.value = 'نتیجه‌ای یافت نشد.'; ok.value = 'نتیجه‌ای یافت نشد.';
} else { } else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length; const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
const pending = syncResults.value.length - linked; const duplicate = syncResults.value.filter((r) => duplicateOwner(r)).length;
const pending = syncResults.value.length - linked - duplicate;
ok.value = ok.value =
`${toFaDigits(syncResults.value.length)} مشتری پیدا شد` + `${toFaDigits(syncResults.value.length)} مشتری پیدا شد` +
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`; ` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده` +
(duplicate ? ` · ${toFaDigits(duplicate)} شماره تکراری` : '');
} }
} catch (e) { } catch (e) {
error.value = apiErr(e, 'جستجو ناموفق'); error.value = apiErr(e, 'جستجو ناموفق');
@@ -951,9 +1043,9 @@ onMounted(load);
<div class="sync-sub-section"> <div class="sync-sub-section">
<h4 class="sync-subhead">بروزرسانی خودکار</h4> <h4 class="sync-subhead">بروزرسانی خودکار</h4>
<p class="sync-sub-desc"> <p class="sync-sub-desc">
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران، «همگام‌سازی الان» همهٔ مشتریان ته‌حساب را می‌خواند: تازه‌ها را وارد می‌کند و برای مشتری‌های از قبل
مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد لینک‌شده نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه را از ته‌حساب تازه می‌کند. زمان‌بندی زیر
همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی. همان کار را در ساعت‌های انتخابی (به وقت تهران) تکرار می‌کند. خاموش یعنی فقط ورود دستی.
</p> </p>
<UiField label="تعداد در روز"> <UiField label="تعداد در روز">
<UiSelect <UiSelect
@@ -986,8 +1078,16 @@ onMounted(load);
</div> </div>
<div class="sync-buttons"> <div class="sync-buttons">
<UiButton <UiButton
:loading="syncBusy"
:disabled="!sync.connectionId || syncBusy || autoSyncSaving"
@click="syncCustomersNow"
>
همگام‌سازی الان (وارد + به‌روز کردن قبلی‌ها)
</UiButton>
<UiButton
variant="secondary"
:loading="autoSyncSaving" :loading="autoSyncSaving"
:disabled="!sync.connectionId" :disabled="!sync.connectionId || syncBusy"
@click="saveAutoSync" @click="saveAutoSync"
> >
ذخیره زمان‌بندی ذخیره زمان‌بندی
@@ -1100,6 +1200,9 @@ onMounted(load);
<h4 class="sync-results-title"> <h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>) نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
</h4> </h4>
<p v-if="duplicateCustomerCount" class="sync-sub-desc">
{{ toFaDigits(duplicateCustomerCount) }} مشتری شمارهٔ تکراری دارند و جداگانه وارد نمی‌شوند؛ همان شخص با کد دیگری از قبل لینک است.
</p>
<div class="tc-row"> <div class="tc-row">
<TableColumnsMenu table-key="tahesab.customers" align="end" /> <TableColumnsMenu table-key="tahesab.customers" align="end" />
</div> </div>
@@ -1120,7 +1223,9 @@ onMounted(load);
</thead> </thead>
<tbody> <tbody>
<tr v-if="!syncPagedResults.length"> <tr v-if="!syncPagedResults.length">
<td colspan="5" class="sync-empty-row">همهٔ مشتریان این نتایج قبلاً وارد شده‌اند.</td> <td colspan="5" class="sync-empty-row">
مشتری تازه‌ای برای ورود نیست. شماره‌های تکراری از قبل با کد دیگری لینک شده‌اند.
</td>
</tr> </tr>
<tr v-for="r in syncPagedResults" :key="String(r.moshtariCode)"> <tr v-for="r in syncPagedResults" :key="String(r.moshtariCode)">
<td class="font-mono">{{ r.moshtariCode }}</td> <td class="font-mono">{{ r.moshtariCode }}</td>
@@ -1130,11 +1235,14 @@ onMounted(load);
<UiBadge v-if="r.alreadyLinked" variant="success"> <UiBadge v-if="r.alreadyLinked" variant="success">
لینک شده{{ r.personName ? ` (${r.personName})` : '' }} لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
</UiBadge> </UiBadge>
<UiBadge v-else-if="duplicateOwner(r)" variant="warning">
شماره تکراری، کد {{ toFaDigits(duplicateOwner(r)!.code) }}
</UiBadge>
<UiBadge v-else variant="neutral">آماده ورود</UiBadge> <UiBadge v-else variant="neutral">آماده ورود</UiBadge>
</td> </td>
<td> <td>
<UiButton <UiButton
v-if="!r.alreadyLinked" v-if="!r.alreadyLinked && !duplicateOwner(r)"
size="sm" size="sm"
:disabled="!r.moshtariCode || syncBusy" :disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)" @click="importOne(r.moshtariCode!)"
@@ -1142,8 +1250,8 @@ onMounted(load);
وارد کردن وارد کردن
</UiButton> </UiButton>
<RouterLink <RouterLink
v-else-if="r.personId" v-else-if="r.personId || duplicateOwner(r)?.personId"
:to="{ name: 'person-detail', params: { id: r.personId } }" :to="{ name: 'person-detail', params: { id: (r.personId || duplicateOwner(r)?.personId)! } }"
> >
<UiButton variant="ghost" size="sm"> <UiButton variant="ghost" size="sm">
مشاهده شخص مشاهده شخص
+4 -4
View File
@@ -138,7 +138,7 @@ const editPassword = ref('');
const editRoleLabel = ref(''); const editRoleLabel = ref('');
const editRoleId = ref(''); const editRoleId = ref('');
const editInitialRoleId = ref(''); const editInitialRoleId = ref('');
/** مدیر نمی‌تواند نقش مالک را عوض کند؛ آن موقع فقط برچسب نقش نشان داده می‌شود */ /** نقش مالک قفل است؛ مالک همان ثبت‌نام‌کننده می‌ماند */
const editRoleLocked = ref(false); const editRoleLocked = ref(false);
const roleFormOpen = ref(false); const roleFormOpen = ref(false);
@@ -253,7 +253,7 @@ const parentRoleOptions = computed(() => {
}); });
const inviteableRoles = computed(() => const inviteableRoles = computed(() =>
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'), roles.value.filter((r) => r.systemKey !== 'owner'),
); );
function roleTreeLabel(role: OrgRoleDto) { function roleTreeLabel(role: OrgRoleDto) {
@@ -482,7 +482,7 @@ function openEditUser(user: OrgUserDto) {
editRoleLabel.value = user.orgRoleName || '—'; editRoleLabel.value = user.orgRoleName || '—';
editRoleId.value = user.orgRoleId ?? ''; editRoleId.value = user.orgRoleId ?? '';
editInitialRoleId.value = user.orgRoleId ?? ''; editInitialRoleId.value = user.orgRoleId ?? '';
editRoleLocked.value = user.role === 'owner' && auth.user?.role !== 'owner'; editRoleLocked.value = user.role === 'owner';
editOpen.value = true; editOpen.value = true;
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
@@ -1517,7 +1517,7 @@ onUnmounted(() => {
</option> </option>
</select> </select>
</label> </label>
<p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — نقش مالک را فقط مالک می‌تواند تغییر دهد.</p> <p v-else class="hint">نقش فعلی: {{ editRoleLabel }} — مالک همان کسی است که ثبت‌نام کرده و عوض نمی‌شود. برای دسترسی کامل نقش مدیر را بدهید.</p>
</form> </form>
<template #footer> <template #footer>
<button type="button" class="btn ghost" @click="editOpen = false">انصراف</button> <button type="button" class="btn ghost" @click="editOpen = false">انصراف</button>
+395 -84
View File
@@ -20,12 +20,20 @@ import {
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
type TabKey = 'generic' | 'daftareshoma';
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const items = ref<VoipConnectionDto[]>([]); const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]); const calls = ref<VoipCallLogDto[]>([]);
const tab = ref<TabKey>('daftareshoma');
const callFilters = reactive({
direction: '',
status: '',
lineExtension: '',
});
const form = reactive({ const form = reactive({
label: 'سانترال اصلی', label: 'سانترال اصلی',
@@ -34,12 +42,44 @@ const form = reactive({
enabled: true, enabled: true,
}); });
const webhookUrl = computed(() => { const daftaForm = reactive({
if (!items.value[0]) return ''; label: 'دفتر شما',
const origin = typeof window !== 'undefined' ? window.location.origin : ''; apiToken: '',
return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`; fromNumber: '',
callerExtension: '',
enabled: true,
}); });
const genericItems = computed(() =>
items.value.filter((c) => c.provider !== 'daftareshoma'),
);
const daftaItems = computed(() =>
items.value.filter((c) => c.provider === 'daftareshoma'),
);
const activeDafta = computed(() => daftaItems.value[0] ?? null);
const knownLines = ref<string[]>([]);
const lineOptions = computed(() => {
const set = new Set(knownLines.value);
for (const c of calls.value) {
if (c.lineExtension) set.add(c.lineExtension);
}
if (callFilters.lineExtension) set.add(callFilters.lineExtension);
return [...set].sort();
});
const webhookUrlFor = (id: string | undefined) => {
if (!id) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/api/public/integrations/voip/${id}/webhook`;
};
const genericWebhookUrl = computed(() =>
webhookUrlFor(genericItems.value[0]?.id),
);
const daftaWebhookUrl = computed(() => webhookUrlFor(activeDafta.value?.id));
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
ringing: 'زنگ', ringing: 'زنگ',
answered: 'پاسخ', answered: 'پاسخ',
@@ -63,16 +103,32 @@ function noteLink(c: VoipCallLogDto): string | null {
return '/notes'; return '/notes';
} }
async function loadCalls() {
const params: Record<string, string> = {};
if (callFilters.direction) params.direction = callFilters.direction;
if (callFilters.status) params.status = callFilters.status;
if (callFilters.lineExtension) params.lineExtension = callFilters.lineExtension;
const callRes = await api.get<VoipCallLogDto[]>('/integrations/voip/calls', {
params,
});
calls.value = callRes.data;
const merged = new Set(knownLines.value);
for (const c of callRes.data) {
if (c.lineExtension) merged.add(c.lineExtension);
}
knownLines.value = [...merged].sort();
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [connRes, callRes] = await Promise.all([ const [connRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'), api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'), loadCalls(),
]); ]);
items.value = connRes.data; items.value = connRes.data;
calls.value = callRes.data; fillDaftaFromActive();
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) { if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.'; error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
@@ -84,16 +140,30 @@ async function load() {
} }
} }
async function create() { async function applyCallFilters() {
loading.value = true;
error.value = '';
try {
await loadCalls();
} catch {
error.value = 'فیلتر تماس‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createGeneric() {
if (!form.label.trim()) { if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.'; error.value = 'نام اتصال را وارد کنید.';
return; return;
} }
saving.value = true; saving.value = true;
error.value = ''; error.value = '';
ok.value = '';
try { try {
const payload: CreateVoipConnectionRequest = { const payload: CreateVoipConnectionRequest = {
label: form.label.trim(), label: form.label.trim(),
provider: 'generic',
webhookSecret: form.webhookSecret.trim() || undefined, webhookSecret: form.webhookSecret.trim() || undefined,
clickToCallUrl: form.clickToCallUrl.trim() || null, clickToCallUrl: form.clickToCallUrl.trim() || null,
enabled: form.enabled, enabled: form.enabled,
@@ -111,6 +181,65 @@ async function create() {
} }
} }
async function saveDafta() {
if (!daftaForm.label.trim()) {
error.value = 'نام اتصال را وارد کنید.';
return;
}
if (!activeDafta.value && !daftaForm.apiToken.trim()) {
error.value = 'توکن API دفتر شما را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (activeDafta.value) {
await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, {
label: daftaForm.label.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
...(daftaForm.apiToken.trim()
? { apiToken: daftaForm.apiToken.trim() }
: {}),
});
daftaForm.apiToken = '';
ok.value = 'اتصال دفتر شما بروزرسانی شد.';
} else {
const payload: CreateVoipConnectionRequest = {
label: daftaForm.label.trim(),
provider: 'daftareshoma',
apiToken: daftaForm.apiToken.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
};
await api.post('/integrations/voip/connections', payload);
daftaForm.apiToken = '';
ok.value =
'اتصال دفتر شما ذخیره شد. آدرس وب‌هوک را در پرتال دفتر شما ثبت کنید.';
}
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
saving.value = false;
}
}
function fillDaftaFromActive() {
const c = activeDafta.value;
if (!c) return;
daftaForm.label = c.label;
daftaForm.fromNumber = c.fromNumber ?? '';
daftaForm.callerExtension = c.callerExtension ?? '';
daftaForm.enabled = c.enabled;
daftaForm.apiToken = '';
}
async function remove(id: string) { async function remove(id: string) {
if (!confirm('این اتصال حذف شود؟')) return; if (!confirm('این اتصال حذف شود؟')) return;
try { try {
@@ -121,18 +250,19 @@ async function remove(id: string) {
} }
} }
async function copyWebhook() { async function copyText(text: string, msg: string) {
if (!webhookUrl.value) return; if (!text) return;
try { try {
await navigator.clipboard.writeText(webhookUrl.value); await navigator.clipboard.writeText(text);
ok.value = 'آدرس وب‌هوک کپی شد.'; ok.value = msg;
} catch { } catch {
error.value = 'کپی ممکن نشد.'; error.value = 'کپی ممکن نشد.';
} }
} }
onMounted(() => { onMounted(async () => {
void load(); await load();
fillDaftaFromActive();
}); });
</script> </script>
@@ -150,81 +280,222 @@ onMounted(() => {
</template> </template>
</UiPageHeader> </UiPageHeader>
<nav class="tabs" aria-label="نوع اتصال تلفن">
<button
type="button"
:class="{ on: tab === 'daftareshoma' }"
@click="tab = 'daftareshoma'"
>
دفتر شما
</button>
<button
type="button"
:class="{ on: tab === 'generic' }"
@click="tab = 'generic'"
>
وب‌هوک عمومی
</button>
</nav>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p> <p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="اتصال جدید"> <template v-if="tab === 'daftareshoma'">
<p class="hint"> <UiCard title="اتصال دفتر شما">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد. <p class="hint">
فیلدهای اصلی: برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
<code>callId</code>، توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<code>direction</code> (inbound/outbound)، <strong>تنظیمات → هوک</strong>
<code>status</code>، با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
<code>from</code>، فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
<code>to</code>. </p>
برای خلاصه خودکار یکی از <div class="form-grid">
<code>recordingUrl</code> <UiField label="نام">
(لینک فایل ضبط) یا <UiInput v-model="daftaForm.label" placeholder="دفتر شما" />
<code>transcript</code> </UiField>
(متن آماده) را بفرستید؛ پس از <UiField
<code>completed</code> :label="activeDafta?.apiTokenSet ? 'توکن API (خالی = بدون تغییر)' : 'توکن API'"
/ >
<code>answered</code> <UiInput
صف خلاصه ساخته می‌شود. v-model="daftaForm.apiToken"
امضا اختیاری با هدر type="password"
<code>X-Voip-Signature</code> placeholder="توکن از پرتال دفتر شما"
(HMAC-SHA256 هگز). autocomplete="off"
</p> />
<div class="form-grid"> </UiField>
<UiField label="نام"> <UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput v-model="form.label" placeholder="سانترال اصلی" /> <UiInput
</UiField> v-model="daftaForm.fromNumber"
<UiField label="رمز وب‌هوک (اختیاری)"> placeholder="موبایل همکار برای click-to-call"
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" /> dir="ltr"
</UiField> />
<UiField label="آدرس Click-to-Call (اختیاری)"> </UiField>
<UiInput <UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
v-model="form.clickToCallUrl" <UiInput
placeholder="https://pbx.example/originate?num={number}" v-model="daftaForm.callerExtension"
/> placeholder="مثلاً 02128425990"
</UiField> dir="ltr"
</div> />
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton> </UiField>
</UiCard> </div>
<UiButton :loading="saving" @click="saveDafta">
{{ activeDafta ? 'بروزرسانی اتصال' : 'ذخیره اتصال دفتر شما' }}
</UiButton>
</UiCard>
<UiCard title="اتصال‌ها"> <UiCard title="اتصال‌های دفتر شما">
<UiEmptyState <UiEmptyState
v-if="!loading && !items.length" v-if="!loading && !daftaItems.length"
title="اتصالی نیست" title="اتصالی نیست"
description="یک اتصال برای دریافت رویداد تماس بسازید." description="توکن و مشخصات خط را ذخیره کنید."
/> />
<div v-else class="list"> <div v-else class="list">
<article v-for="c in items" :key="c.id" class="item"> <article v-for="c in daftaItems" :key="c.id" class="item">
<div> <div>
<strong>{{ c.label }}</strong> <strong>{{ c.label }}</strong>
<p class="muted"> <p class="muted">
تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }} تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }}
· ·
{{ {{
c.lastEventAt c.lastEventAt
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}` ? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
: 'هنوز رویدادی نیامده' : 'هنوز رویدادی نیامده'
}} }}
</p> </p>
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p> <p class="muted">
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p> مبدأ {{ c.fromNumber || '—' }} · خط {{ c.callerExtension || '—' }}
</div> · توکن {{ c.apiTokenSet ? 'ثبت شده' : 'ندارد' }}
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton> </p>
</article> <p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div> </div>
<div v-if="webhookUrl" class="webhook"> <UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
<p class="hint">آدرس وب‌هوک:</p> </article>
<code>{{ webhookUrl }}</code> </div>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton> <div v-if="daftaWebhookUrl" class="webhook">
</div> <p class="hint">
</UiCard> آدرس وب‌هوک برای پرتال دفتر شما (رویدادها را با JSON به این آدرس POST کنید):
</p>
<code>{{ daftaWebhookUrl }}</code>
<UiButton
variant="secondary"
@click="copyText(daftaWebhookUrl, 'آدرس وب‌هوک دفتر شما کپی شد.')"
>
کپی
</UiButton>
</div>
</UiCard>
</template>
<template v-else>
<UiCard title="اتصال جدید">
<p class="hint">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد.
فیلدهای اصلی:
<code>callId</code>،
<code>direction</code> (inbound/outbound)،
<code>status</code>،
<code>from</code>،
<code>to</code>.
برای خلاصه خودکار یکی از
<code>recordingUrl</code>
(لینک فایل ضبط) یا
<code>transcript</code>
(متن آماده) را بفرستید؛ پس از
<code>completed</code>
/
<code>answered</code>
صف خلاصه ساخته می‌شود.
امضا اختیاری با هدر
<code>X-Voip-Signature</code>
(HMAC-SHA256 هگز).
</p>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
</UiField>
<UiField label="رمز وب‌هوک (اختیاری)">
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
</UiField>
<UiField label="آدرس Click-to-Call (اختیاری)">
<UiInput
v-model="form.clickToCallUrl"
placeholder="https://pbx.example/originate?num={number}"
/>
</UiField>
</div>
<UiButton :loading="saving" @click="createGeneric">ذخیره اتصال</UiButton>
</UiCard>
<UiCard title="اتصال‌ها">
<UiEmptyState
v-if="!loading && !genericItems.length"
title="اتصالی نیست"
description="یک اتصال برای دریافت رویداد تماس بسازید."
/>
<div v-else class="list">
<article v-for="c in genericItems" :key="c.id" class="item">
<div>
<strong>{{ c.label }}</strong>
<p class="muted">
تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }}
·
{{
c.lastEventAt
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
: 'هنوز رویدادی نیامده'
}}
</p>
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
</article>
</div>
<div v-if="genericWebhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک:</p>
<code>{{ genericWebhookUrl }}</code>
<UiButton
variant="secondary"
@click="copyText(genericWebhookUrl, 'آدرس وب‌هوک کپی شد.')"
>
کپی
</UiButton>
</div>
</UiCard>
</template>
<UiCard title="آخرین تماس‌ها"> <UiCard title="آخرین تماس‌ها">
<div class="call-filters">
<UiField label="جهت">
<select v-model="callFilters.direction" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="inbound">ورودی</option>
<option value="outbound">خروجی</option>
</select>
</UiField>
<UiField label="وضعیت">
<select v-model="callFilters.status" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="ringing">زنگ</option>
<option value="answered">پاسخ</option>
<option value="missed">از دست‌رفته</option>
<option value="completed">تمام‌شده</option>
<option value="failed">ناموفق</option>
</select>
</UiField>
<UiField label="خط">
<select
v-model="callFilters.lineExtension"
class="filter-select"
@change="applyCallFilters"
>
<option value="">همه خطوط</option>
<option v-for="line in lineOptions" :key="line" :value="line">
{{ line }}
</option>
</select>
</UiField>
</div>
<UiEmptyState <UiEmptyState
v-if="!calls.length" v-if="!calls.length"
title="تماسی نیست" title="تماسی نیست"
@@ -239,6 +510,7 @@ onMounted(() => {
<tr> <tr>
<th>جهت</th> <th>جهت</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>خط</th>
<th>از / به</th> <th>از / به</th>
<th>مشتری</th> <th>مشتری</th>
<th>مدت</th> <th>مدت</th>
@@ -250,6 +522,7 @@ onMounted(() => {
<tr v-for="c in calls" :key="c.id"> <tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td> <td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td> <td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td> <td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td> <td>{{ c.personName || '—' }}</td>
<td> <td>
@@ -294,6 +567,28 @@ onMounted(() => {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.tabs {
display: flex;
justify-content: flex-start;
gap: 1.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
appearance: none;
background: none;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.55rem 0.15rem;
color: #94a3b8;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tabs button.on {
color: #0d9488;
border-bottom-color: #14b8a6;
}
.hint, .hint,
.muted { .muted {
color: #64748b; color: #64748b;
@@ -312,6 +607,21 @@ onMounted(() => {
gap: 0.75rem; gap: 0.75rem;
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
} }
.call-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-select {
width: 100%;
min-height: 36px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.6rem;
background: #fff;
font: inherit;
}
.list { .list {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -404,7 +714,8 @@ th {
color: #0f766e; color: #0f766e;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.form-grid { .form-grid,
.call-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
+20 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, StorageUsageDto } from '@mento/shared'; import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, ProductPlanCode, StorageUsageDto } from '@mento/shared';
import { import {
BILLING_PLAN_CODES, BILLING_PLAN_CODES,
BILLING_PLAN_LABELS, BILLING_PLAN_LABELS,
BILLING_USER_CAPS, BILLING_USER_CAPS,
PRODUCT_PLAN_CODES,
PRODUCT_PLAN_LABELS,
isPermanentBillingPlan, isPermanentBillingPlan,
} from '@mento/shared'; } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
@@ -19,6 +21,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
apply: [planCode: BillingPlanCode]; apply: [planCode: BillingPlanCode];
product: [planCode: ProductPlanCode];
}>(); }>();
const usage = ref<StorageUsageDto | null>(null); const usage = ref<StorageUsageDto | null>(null);
@@ -178,6 +181,22 @@ onMounted(() => {
{{ BILLING_PLAN_LABELS[code] }} {{ BILLING_PLAN_LABELS[code] }}
</button> </button>
</div> </div>
<strong>سطح امکانات</strong>
<p class="muted">
انتخاب سطح، دسترسی محیط‌های این مالک را طبق تعرفه تیک می‌زند. هسته و تیم و سازمان سقف کاربر و محیط را هم تنظیم می‌کنند. ثبت‌نام دمو از هسته شروع می‌شود.
</p>
<div class="plan-row">
<button
v-for="code in PRODUCT_PLAN_CODES"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="busyId === `product-${userId}-${code}`"
@click="emit('product', code)"
>
{{ PRODUCT_PLAN_LABELS[code] }}
</button>
</div>
<div v-if="billing" class="storage"> <div v-if="billing" class="storage">
<strong>تعداد کاربر</strong> <strong>تعداد کاربر</strong>
<p v-if="seats" class="muted"> <p v-if="seats" class="muted">
@@ -6,8 +6,9 @@ import type {
BillingPlanCode, BillingPlanCode,
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformOrgMemberDto, PlatformOrgMemberDto,
ProductPlanCode,
} from '@mento/shared'; } from '@mento/shared';
import { BILLING_PLAN_LABELS } from '@mento/shared'; import { BILLING_PLAN_LABELS, PRODUCT_PLAN_LABELS } from '@mento/shared';
import { platformApi } from '@/lib/platform-api'; import { platformApi } from '@/lib/platform-api';
import AdmBillingPanel from './AdmBillingPanel.vue'; import AdmBillingPanel from './AdmBillingPanel.vue';
@@ -50,7 +51,8 @@ async function loadOwners() {
const { data } = await platformApi.get<PlatformOrganizationDto>( const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${orgId.value}`, `/platform/organizations/${orgId.value}`,
); );
owners.value = (data.members ?? []).filter((member) => member.role === 'owner'); const ownersInOrder = (data.members ?? []).filter((member) => member.role === 'owner');
owners.value = ownersInOrder.slice(0, 1);
} catch (e) { } catch (e) {
error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود'); error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود');
} finally { } finally {
@@ -58,6 +60,21 @@ async function loadOwners() {
} }
} }
async function applyProduct(userId: string, planCode: ProductPlanCode) {
busyId.value = `product-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
await platformApi.post(`/platform/organizations/${orgId.value}/product-plan`, { planCode });
await loadOwners();
success.value = `سطح «${PRODUCT_PLAN_LABELS[planCode]}» روی دسترسی‌ها اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال سطح امکانات ناموفق بود');
} finally {
busyId.value = '';
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) { async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`; busyId.value = `plan-${userId}-${planCode}`;
error.value = ''; error.value = '';
@@ -100,11 +117,12 @@ onMounted(() => {
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p> <p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
<AdmBillingPanel <AdmBillingPanel
v-for="owner in owners" v-for="owner in owners"
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}`" :key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}-${owner.billing?.maxUsers ?? ''}-${owner.billing?.maxOrganizations ?? ''}`"
:user-id="owner.userId" :user-id="owner.userId"
:billing="owner.billing" :billing="owner.billing"
:busy-id="busyId" :busy-id="busyId"
@apply="applyPlan(owner.userId, $event)" @apply="applyPlan(owner.userId, $event)"
@product="applyProduct(owner.userId, $event)"
/> />
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import type {
CreatePlatformOrganizationRequest, CreatePlatformOrganizationRequest,
OrgModuleKey, OrgModuleKey,
PlatformOrganizationDto, PlatformOrganizationDto,
ProductPlanCode,
UpdatePlatformOrganizationRequest, UpdatePlatformOrganizationRequest,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
@@ -12,6 +13,8 @@ import {
BILLING_PLAN_LABELS, BILLING_PLAN_LABELS,
ORG_MODULE_GROUPS, ORG_MODULE_GROUPS,
ORG_MODULE_LABELS, ORG_MODULE_LABELS,
PRODUCT_PLAN_CODES,
PRODUCT_PLAN_LABELS,
USER_ROLE_LABELS, USER_ROLE_LABELS,
USER_ROLES, USER_ROLES,
isBusinessField, isBusinessField,
@@ -265,6 +268,25 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
} }
} }
async function applyProductPlan(orgId: string, planCode: ProductPlanCode) {
savingModules.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.post<PlatformOrganizationDto>(
`/platform/organizations/${orgId}/product-plan`,
{ planCode },
);
moduleDraft.value = [...(data.enabledModules ?? [])];
if (detail.value?.id === orgId) detail.value = data;
success.value = `سطح «${PRODUCT_PLAN_LABELS[planCode]}» روی دسترسی‌ها اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال سطح امکانات ناموفق بود');
} finally {
savingModules.value = false;
}
}
function toggleModule(key: OrgModuleKey) { function toggleModule(key: OrgModuleKey) {
if (moduleDraft.value.includes(key)) { if (moduleDraft.value.includes(key)) {
moduleDraft.value = moduleDraft.value.filter((k) => k !== key); moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
@@ -434,6 +456,16 @@ onMounted(() => {
<div class="modules"> <div class="modules">
<div class="modules-head"> <div class="modules-head">
<strong>ماژول‌های فعال</strong> <strong>ماژول‌های فعال</strong>
<button
v-for="code in PRODUCT_PLAN_CODES"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="savingModules"
@click="applyProductPlan(org.id, code)"
>
{{ PRODUCT_PLAN_LABELS[code] }}
</button>
<button <button
class="btn" class="btn"
type="button" type="button"
@@ -522,7 +554,8 @@ onMounted(() => {
<div class="member-actions"> <div class="member-actions">
<select <select
:value="member.role" :value="member.role"
:disabled="busyId === `role-${member.membershipId}`" :disabled="member.role === 'owner' || busyId === `role-${member.membershipId}`"
:title="member.role === 'owner' ? 'مالک همان ثبت‌نام‌کننده است و عوض نمی‌شود' : undefined"
@change=" @change="
setMembershipRole( setMembershipRole(
member.membershipId, member.membershipId,
@@ -530,7 +563,11 @@ onMounted(() => {
) )
" "
> >
<option v-for="role in USER_ROLES" :key="role" :value="role"> <option
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || member.role === 'owner')"
:key="role"
:value="role"
>
{{ USER_ROLE_LABELS[role] }} {{ USER_ROLE_LABELS[role] }}
</option> </option>
</select> </select>
+7 -2
View File
@@ -247,7 +247,8 @@ onMounted(() => {
<div class="member-actions"> <div class="member-actions">
<select <select
:value="mem.role" :value="mem.role"
:disabled="busyId === `role-${mem.membershipId}`" :disabled="mem.role === 'owner' || busyId === `role-${mem.membershipId}`"
:title="mem.role === 'owner' ? 'مالک همان ثبت‌نام‌کننده است و عوض نمی‌شود' : undefined"
@change=" @change="
setMembershipRole( setMembershipRole(
user.id, user.id,
@@ -256,7 +257,11 @@ onMounted(() => {
) )
" "
> >
<option v-for="role in USER_ROLES" :key="role" :value="role"> <option
v-for="role in USER_ROLES.filter((role) => role !== 'owner' || mem.role === 'owner')"
:key="role"
:value="role"
>
{{ USER_ROLE_LABELS[role] }} {{ USER_ROLE_LABELS[role] }}
</option> </option>
</select> </select>
@@ -16,12 +16,16 @@ import {
Space as ASpace, Space as ASpace,
Statistic as AStatistic, Statistic as AStatistic,
Table as ATable, Table as ATable,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
List as AList,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared'; import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryPageChrome } from './treasuryPageChrome'; import { useTreasuryPageChrome } from './treasuryPageChrome';
@@ -37,6 +41,8 @@ const reason = ref('');
const note = ref(''); const note = ref('');
const busy = ref(false); const busy = ref(false);
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const columns: TableColumnsType<FinancialMovementDto> = [ const columns: TableColumnsType<FinancialMovementDto> = [
{ {
title: 'تاریخ', title: 'تاریخ',
@@ -168,7 +174,7 @@ onUnmounted(() => {
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="موجودی واقعی"> <a-form-item label="موجودی واقعی">
<MoneyInput v-model="stated" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="stated" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -190,8 +196,29 @@ onUnmounted(() => {
<a-divider orientation="right">گردش حساب</a-divider> <a-divider orientation="right">گردش حساب</a-divider>
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." /> <a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
<a-list v-else-if="isMobile" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="formatJalaliNumeric(new Date(item.occurredAt))" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="شرح">{{ item.memo || '—' }}</a-descriptions-item>
<a-descriptions-item label="نوع">{{ item.kindLabel }}</a-descriptions-item>
<a-descriptions-item label="ورودی">
{{ item.direction === 'in' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="خروجی">
{{ item.direction === 'out' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="مانده">
{{ item.balanceAfter ? toman(item.balanceAfter) : '—' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else v-else-if="items.length"
:columns="columns" :columns="columns"
:data-source="items" :data-source="items"
:pagination="false" :pagination="false"
@@ -27,9 +27,11 @@ import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@men
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
@@ -315,7 +317,7 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance"> <a-form-item label="موجودی اولیه" name="openingBalance">
<MoneyInput v-model="form.openingBalance" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.openingBalance" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -400,7 +402,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="transfer.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="transfer.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
@@ -420,7 +422,7 @@ onMounted(() => {
<a-divider orientation="right">فهرست حساب‌ها</a-divider> <a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." /> <a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="accounts">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -23,8 +23,10 @@ import {
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared'; import type { FinancialCategoryDto } from '@mento/shared';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
@@ -151,7 +153,7 @@ onMounted(() => {
<a-divider orientation="right">درآمد</a-divider> <a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." /> <a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="incomeRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -176,7 +178,7 @@ onMounted(() => {
<a-divider orientation="right">هزینه</a-divider> <a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." /> <a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="expenseRows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="expenseRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -158,7 +158,7 @@ onMounted(() => {
<UiCard title="سند جدید"> <UiCard title="سند جدید">
<div class="grid"> <div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField> <UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></UiField> <UiField label="مبلغ کل"><MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دسته‌بندی"> <UiField label="دسته‌بندی">
<select v-model="form.categoryId" class="select"> <select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option> <option value="">بدون دسته</option>
@@ -166,7 +166,7 @@ onMounted(() => {
</select> </select>
</UiField> </UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField> <UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField :label="settleLabel"><MoneyInput v-model="form.settleAmount" :decimals="0" /></UiField> <UiField :label="settleLabel"><MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" /></UiField>
<UiField label="حساب"> <UiField label="حساب">
<select v-model="form.accountId" class="select"> <select v-model="form.accountId" class="select">
<option value="">انتخاب حساب</option> <option value="">انتخاب حساب</option>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -14,11 +14,13 @@ import {
Input as AInput, Input as AInput,
List as AList, List as AList,
Row as ARow, Row as ARow,
Segmented as ASegmented,
Select as ASelect, Select as ASelect,
Typography, Typography,
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type { import type {
FinancialAccountDto, FinancialAccountDto,
FinancialCategoryDto, FinancialCategoryDto,
@@ -26,11 +28,13 @@ import type {
FinancialPartyKind, FinancialPartyKind,
TreasuryPartyBalanceDto, TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format'; import { partyNetPosition, toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
@@ -38,9 +42,11 @@ const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const isMobile = useTreasuryMobile();
const kind = 'expense' as const; const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]); const balances = ref<TreasuryPartyBalanceDto[]>([]);
const receivableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const error = ref(''); const error = ref('');
@@ -57,10 +63,17 @@ const form = ref({
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()),
}); });
const settleMirror = useSettleDefault(form); const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده'; const settleLabel = 'مبلغ پرداخت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'پرداخت', value: 'settlement' },
{ label: 'هزینه', value: 'accrual' },
];
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده', open: 'پرداخت‌نشده',
partial: 'جزئی', partial: 'جزئی',
@@ -94,17 +107,32 @@ function docLine(item: FinancialDocumentDto) {
} }
async function load() { async function load() {
const [docs, cats, acc, bals] = await Promise.all([ const [docs, cats, acc, bals, receivables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
api.get<FinancialAccountDto[]>('/treasury/accounts'), if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialCategoryDto[] };
}
throw e;
}),
api.get<FinancialAccountDto[]>('/treasury/accounts').catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialAccountDto[] };
}
throw e;
}),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e; throw e;
}), }),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]); ]);
rows.value = docs.data; rows.value = docs.data;
balances.value = bals.data; balances.value = bals.data;
receivableBalances.value = receivables.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
@@ -118,33 +146,61 @@ async function createDoc() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.'; error.value = settlement ? 'مبلغ پرداخت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.'; error.value = 'طرف حساب را انتخاب کنید.';
return; return;
} }
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; if (settlement && form.value.partyKind === 'other') {
if (form.value.partyKind === 'other' && settleAmount !== amount) { error.value = 'برای پرداخت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.'; error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return; return;
} }
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ پرداخت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const payable = partyKey
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const receivable = partyKey
? Number(receivableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (payable > 0 || receivable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', { const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
settlement,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null, personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null, companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null, userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount: settlement ? amount : settleAmount,
method: form.value.method, method: form.value.method,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
}); });
settleMirror.reset(); settleMirror.reset();
form.value = { form.value = {
@@ -157,10 +213,13 @@ async function createDoc() {
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()),
}; };
ok.value = data.reference ok.value = collectsBalance
? `سند ثبت شد. شماره پیگیری: ${data.reference}` ? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
: 'سند ثبت شد.'; : data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -169,6 +228,57 @@ async function createDoc() {
} }
} }
const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای پرداخت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
},
},
];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
if (!partyKind || partyKind === 'other' || !id) return null;
const key = `${partyKind}:${id}`;
const receivable = Number(receivableBalances.value.find((item) => item.key === key)?.remaining || 0);
const payable = Number(balances.value.find((item) => item.key === key)?.remaining || 0);
return partyNetPosition(receivable, payable);
});
async function voidDoc(id: string) { async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try { try {
@@ -192,7 +302,10 @@ onMounted(() => {
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider> <a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form <a-form
layout="vertical" layout="vertical"
:model="form" :model="form"
@@ -200,6 +313,23 @@ onMounted(() => {
@finishFailed="onFinishFailed" @finishFailed="onFinishFailed"
> >
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="عنوان" label="عنوان"
@@ -209,21 +339,35 @@ onMounted(() => {
<a-input v-model:value="form.title" /> <a-input v-model:value="form.title" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item <a-form-item
label="مبلغ کل" label="مبلغ کل"
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> :label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
@@ -238,12 +382,7 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyKind"> <a-form-item label="حساب" name="accountId" :rules="accountRules">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
@@ -265,8 +404,18 @@ onMounted(() => {
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :span="24">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider> <a-divider orientation="right">مانده پرداختنی</a-divider>
@@ -290,10 +439,10 @@ onMounted(() => {
</a-form> </a-form>
<a-empty v-if="!rows.length" description="سندی نیست." /> <a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text> <a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button <a-button
v-if="item.status !== 'void'" v-if="item.status !== 'void'"
@@ -304,7 +453,29 @@ onMounted(() => {
ابطال ابطال
</a-button> </a-button>
</template> </template>
<a-list-item-meta> <a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template> <template #description>{{ docLine(item) }}</template>
</a-list-item-meta> </a-list-item-meta>
@@ -15,8 +15,10 @@ import {
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared'; import type { TreasuryDashboardDto } from '@mento/shared';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
@@ -28,9 +30,9 @@ const cards = [
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' }, { key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' }, { key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' }, { key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' }, { key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد و فروش فاکتور − هزینه' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' }, { key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'اسناد باز و مانده فاکتور' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' }, { key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه باز، حقوق و برگشتی' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' }, { key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const; ] as const;
@@ -75,7 +77,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingInstallments" :data-source="data.upcomingInstallments"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -102,7 +104,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingPayables" :data-source="data.upcomingPayables"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -127,7 +129,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.recent" :data-source="data.recent"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Avatar as AAvatar,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Col as ACol, Col as ACol,
@@ -18,23 +17,9 @@ import {
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
Select as ASelect, Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography, Typography,
message, message,
theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
TagOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali'; import { formatLocalDateInput } from '@/lib/jalali';
import type { import type {
@@ -42,7 +27,6 @@ import type {
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialPartyKind, FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto, TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -50,29 +34,26 @@ import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format'; import { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md); const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const; const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]); const balances = ref<TreasuryPartyBalanceDto[]>([]);
const payableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const saving = ref(false); const saving = ref(false);
const q = ref(''); const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const mobileFormRef = ref<FormInstance>();
function emptyForm() { function emptyForm() {
return { return {
@@ -93,6 +74,12 @@ const form = ref(emptyForm());
const settleMirror = useSettleDefault(form); const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده'; const settleLabel = 'مبلغ دریافت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'دریافت', value: 'settlement' },
{ label: 'درآمد', value: 'accrual' },
];
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده', open: 'دریافت‌نشده',
partial: 'جزئی', partial: 'جزئی',
@@ -108,19 +95,51 @@ const methodOptions = [
{ value: 'other', label: 'سایر' }, { value: 'other', label: 'سایر' },
]; ];
const formTabOptions = [ const partyRules = [
{ {
value: 'main', validator: async () => {
payload: 'main', if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']), if (isSettlement.value && form.value.partyKind === 'other') {
}, throw new Error('برای دریافت، طرف حساب واقعی لازم است');
{ }
value: 'extra', if (form.value.partyKind !== 'other' && !form.value.partyId) {
payload: 'extra', throw new Error('طرف حساب را انتخاب کنید');
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']), }
},
}, },
]; ];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
if (!partyKind || partyKind === 'other' || !id) return null;
const key = `${partyKind}:${id}`;
const receivable = Number(balances.value.find((item) => item.key === key)?.remaining || 0);
const payable = Number(payableBalances.value.find((item) => item.key === key)?.remaining || 0);
return partyNetPosition(receivable, payable);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const remainingAmount = computed(() => { const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0; const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
@@ -149,27 +168,39 @@ function docLine(item: FinancialDocumentDto) {
} }
async function load() { async function load() {
const [docs, cats, acc, dash, bals] = await Promise.all([ const [docs, cats, acc, bals, payables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }), api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
api.get<FinancialAccountDto[]>('/treasury/accounts'), if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null), return { data: [] as FinancialCategoryDto[] };
}
throw e;
}),
api.get<FinancialAccountDto[]>('/treasury/accounts').catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
return { data: [] as FinancialAccountDto[] };
}
throw e;
}),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e; throw e;
}), }),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]); ]);
rows.value = docs.data; rows.value = docs.data;
balances.value = bals.data; balances.value = bals.data;
payableBalances.value = payables.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
} }
function resetForm() { function resetForm() {
settleMirror.reset(); settleMirror.reset();
form.value = emptyForm(); form.value = emptyForm();
formTab.value = 'main';
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
} }
@@ -179,39 +210,68 @@ async function createDoc() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.'; error.value = settlement ? 'مبلغ دریافت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.'; error.value = 'طرف حساب را انتخاب کنید.';
return; return;
} }
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; if (settlement && form.value.partyKind === 'other') {
if (form.value.partyKind === 'other' && settleAmount !== amount) { error.value = 'برای دریافت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.'; error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return; return;
} }
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ دریافت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const receivable = partyKey
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const payable = partyKey
? Number(payableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (receivable > 0 || payable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', { const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
settlement,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null, personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null, companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null, userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount: settlement ? amount : settleAmount,
method: form.value.method, method: form.value.method,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined, bookedAt: form.value.bookedAt || undefined,
}); });
resetForm(); resetForm();
ok.value = data.reference ok.value = collectsBalance
? `سند ثبت شد. شماره پیگیری: ${data.reference}` ? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
: 'سند ثبت شد.'; : data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -220,16 +280,6 @@ async function createDoc() {
} }
} }
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await createDoc();
}
async function voidDoc(id: string) { async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try { try {
@@ -248,340 +298,15 @@ onMounted(() => {
</script> </script>
<template> <template>
<div <a-flex vertical :gap="16">
:style="
isMobile
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
: undefined
"
>
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
دریافت‌های این ماه
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.actualReceipts) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ArrowDownOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
در انتظار دریافت
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.receivables) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ClockCircleOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-segmented
v-model:value="formTab"
block
:options="formTabOptions"
:style="{ background: token.colorFillTertiary }"
/>
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item
label="عنوان درآمد"
name="title"
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
>
<a-input
v-model:value="form.title"
placeholder="مثلاً: دریافت قسط پروژه الف"
>
<template #prefix><FileTextOutlined /></template>
</a-input>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="width: 100%"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item
label="مبلغ کل (تومان)"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="`${settleLabel} (تومان)`"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex
align="center"
justify="space-between"
:style="{
background: token.colorSuccessBg,
borderRadius: `${token.borderRadiusLG * 2}px`,
padding: `${token.paddingSM}px ${token.padding}px`,
marginBottom: `${token.margin}px`,
}"
>
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: token.colorSuccess,
display: 'inline-block',
}"
/>
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
</a-space>
<a-typography-text strong :style="{ color: token.colorSuccess }">
{{ toman(remainingAmount) }}
</a-typography-text>
</a-flex>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="تاریخ دریافت" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="accountId">
<template #label>
<a-space :size="6">
<BankOutlined />
<span>حساب مقصد</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
style="width: 100%"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
:rows="4"
placeholder="توضیحات اختیاری…"
/>
</a-form-item>
</div>
</a-form>
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
<div
:style="{
position: 'fixed',
left: 0,
right: 0,
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
zIndex: 90,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: `${token.paddingSM}px ${token.padding}px`,
boxShadow: token.boxShadowSecondary,
}"
>
<a-row :gutter="12">
<a-col :span="8">
<a-button block @click="resetForm">انصراف</a-button>
</a-col>
<a-col :span="16">
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
<template #icon><CheckOutlined /></template>
ثبت دریافت
</a-button>
</a-col>
</a-row>
</div>
</template>
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider> <a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form <a-form
layout="vertical" layout="vertical"
:model="form" :model="form"
@@ -589,6 +314,23 @@ onMounted(() => {
@finishFailed="onFinishFailed" @finishFailed="onFinishFailed"
> >
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="عنوان" label="عنوان"
@@ -598,21 +340,35 @@ onMounted(() => {
<a-input v-model:value="form.title" /> <a-input v-model:value="form.title" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item <a-form-item
label="مبلغ کل" label="مبلغ کل"
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> :label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
@@ -627,12 +383,7 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyKind"> <a-form-item label="حساب" name="accountId" :rules="accountRules">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
@@ -669,13 +420,13 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" /> <JalaliDateInput v-model="form.bookedAt" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="16"> <a-col v-if="!isSettlement" :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes"> <a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" /> <a-input v-model:value="form.notes" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider> <a-divider orientation="right">مانده دریافتنی</a-divider>
@@ -698,10 +449,10 @@ onMounted(() => {
</a-form> </a-form>
<a-empty v-if="!rows.length" description="سندی نیست." /> <a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text> <a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button <a-button
v-if="item.status !== 'void'" v-if="item.status !== 'void'"
@@ -712,7 +463,29 @@ onMounted(() => {
ابطال ابطال
</a-button> </a-button>
</template> </template>
<a-list-item-meta> <a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template> <template #description>{{ docLine(item) }}</template>
</a-list-item-meta> </a-list-item-meta>
@@ -720,8 +493,5 @@ onMounted(() => {
</template> </template>
</a-list> </a-list>
</a-card> </a-card>
</a-flex> </a-flex>
</template>
</div>
</template> </template>
+12 -7
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { RouterView, useRoute } from 'vue-router'; import { RouterView, useRoute } from 'vue-router';
import { Flex as AFlex, Typography, theme } from 'ant-design-vue'; import { Flex as AFlex, Grid, Typography, theme } from 'ant-design-vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { provideTreasuryPageChrome } from './treasuryPageChrome'; import { provideTreasuryPageChrome } from './treasuryPageChrome';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const route = useRoute(); const route = useRoute();
const chrome = provideTreasuryPageChrome(); const chrome = provideTreasuryPageChrome();
@@ -31,16 +33,19 @@ const description = computed(
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''), (typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
); );
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */ const headerStyle = computed(() =>
const headerStyle = computed(() => ({ isMobile.value
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`, ? undefined
})); : {
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
},
);
</script> </script>
<template> <template>
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }"> <a-flex vertical :gap="isMobile ? token.marginSM : token.margin" :style="{ width: '100%' }">
<div :style="headerStyle"> <div :style="headerStyle">
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title> <a-typography-title :level="isMobile ? 4 : 3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text <a-typography-text
type="secondary" type="secondary"
:ellipsis="description ? { tooltip: description } : false" :ellipsis="description ? { tooltip: description } : false"
File diff suppressed because it is too large Load Diff
@@ -7,12 +7,15 @@ import {
Card as ACard, Card as ACard,
Checkbox as ACheckbox, Checkbox as ACheckbox,
Col as ACol, Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider, Divider as ADivider,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Space as ASpace, Space as ASpace,
@@ -27,6 +30,7 @@ import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared'
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -87,6 +91,9 @@ const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: '', key: 'actions' }, { title: '', key: 'actions' },
]; ];
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
function apiMessage(e: unknown) { function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.'; if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -234,12 +241,12 @@ onMounted(() => {
name="totalAmount" name="totalAmount"
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]" :rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
> >
<MoneyInput v-model="form.totalAmount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.totalAmount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount"> <a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<MoneyInput v-model="form.prepaidAmount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.prepaidAmount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -285,7 +292,7 @@ onMounted(() => {
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]"> <a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`"> <a-form-item :label="`مبلغ قسط ${index + 1}`">
<MoneyInput v-model="line.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="line.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
@@ -320,7 +327,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="collect.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="collect.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -365,7 +372,33 @@ onMounted(() => {
</div> </div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text> <a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex> </a-flex>
<a-list v-if="isMobile" :data-source="row.installments">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="`قسط ${item.position}`" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="سررسید">
{{ formatJalaliNumeric(new Date(item.dueDate)) }}
</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
<a-descriptions-item label="مانده">{{ toman(item.remaining) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">
{{ statusLabel[item.status] ?? item.status }}
</a-descriptions-item>
</a-descriptions>
<a-button
v-if="item.status !== 'settled'"
type="link"
@click="startCollect(row, item)"
>
دریافت
</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else
:columns="installmentColumns" :columns="installmentColumns"
:data-source="row.installments" :data-source="row.installments"
:pagination="false" :pagination="false"

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