Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de77066cb0 | ||
|
|
d23dd20237 | ||
|
|
8a4bd70ad4 | ||
|
|
d87321c7bf | ||
|
|
1a05eee46d | ||
|
|
ec9a7cf9c9 | ||
|
|
4f08ffae36 | ||
|
|
d7cb92bf81 | ||
|
|
871d7bba1a | ||
|
|
f13a1249af | ||
|
|
fdf1fbe4aa | ||
|
|
6df198321a | ||
|
|
4c8604d346 | ||
|
|
25a24772fc | ||
|
|
932dc3f1c1 | ||
|
|
5b26b9156d | ||
|
|
d12d7b91cb | ||
|
|
49459707cb | ||
|
|
ba7f942059 | ||
|
|
96105468d4 | ||
|
|
7a8007fff0 | ||
|
|
da851bd9e5 | ||
|
|
dae3ae3f7e | ||
|
|
b03d97dd9e | ||
|
|
8b8d01976d | ||
|
|
4764562bd4 | ||
|
|
0e1bc4a1fe | ||
|
|
3c9b906bd6 | ||
|
|
ef58922e13 | ||
|
|
06036b00d3 | ||
|
|
d33d88e76d | ||
|
|
5ef693990c | ||
|
|
6ebd45f2cb | ||
|
|
33f33f9521 | ||
|
|
97f00d7e6d | ||
|
|
b8c49293f7 | ||
|
|
f36711174e | ||
|
|
abb1a3a18e | ||
|
|
21d2c69227 | ||
|
|
0ca9540524 | ||
|
|
dc691c5192 |
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
**/dist
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
apps/stt/.venv
|
||||||
|
uploads
|
||||||
|
docs
|
||||||
|
e2e
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
graphify-out
|
||||||
|
.worktrees
|
||||||
|
*.md
|
||||||
|
crm-node_modules.tgz
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Start time
|
||||||
|
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Free disk space
|
||||||
|
id: prune
|
||||||
|
run: |
|
||||||
|
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
|
||||||
|
docker builder prune -af || true
|
||||||
|
docker image prune -af || true
|
||||||
|
docker system prune -af --volumes || true
|
||||||
|
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
id: checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
TAG="$(git rev-parse --short HEAD)"
|
||||||
|
fi
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build Docker images
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tag="${{ steps.meta.outputs.tag }}"
|
||||||
|
reg="${{ steps.meta.outputs.registry }}"
|
||||||
|
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
|
||||||
|
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
|
||||||
|
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
|
||||||
|
|
||||||
|
# Registry login is unsolved. These two steps stay off so a tag run
|
||||||
|
# only proves the three images build. Remove `if: false` on both when
|
||||||
|
# dock.monsys.ir accepts the runner.
|
||||||
|
- name: Login to registry
|
||||||
|
id: login
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
USER_NAME="${REGISTRY_USERNAME:-admin}"
|
||||||
|
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Push Docker images
|
||||||
|
id: push
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tag="${{ steps.meta.outputs.tag }}"
|
||||||
|
reg="${{ steps.meta.outputs.registry }}"
|
||||||
|
for name in montact-api montact-web montact-stt; do
|
||||||
|
docker push "$reg/$name:$tag"
|
||||||
|
docker push "$reg/$name:latest"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Notify Bale
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
|
||||||
|
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
|
||||||
|
TAG: ${{ steps.meta.outputs.tag }}
|
||||||
|
REG: ${{ steps.meta.outputs.registry }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
|
||||||
|
STEP_OUTCOMES: |
|
||||||
|
Checkout code=${{ steps.checkout.outcome }}
|
||||||
|
Resolve image tag=${{ steps.meta.outcome }}
|
||||||
|
Build Docker images=${{ steps.build.outcome }}
|
||||||
|
Login to registry=${{ steps.login.outcome }}
|
||||||
|
Push Docker images=${{ steps.push.outcome }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
|
||||||
|
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
|
||||||
|
if [ -n "$failed_step" ]; then
|
||||||
|
title="❌ بیلد ناموفق"
|
||||||
|
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
|
||||||
|
title="✅ بیلد موفق"
|
||||||
|
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
|
||||||
|
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
|
||||||
|
else
|
||||||
|
title="⚠️ بیلد کامل نشد"
|
||||||
|
fi
|
||||||
|
|
||||||
|
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
|
||||||
|
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
|
||||||
|
|
||||||
|
text="${title}
|
||||||
|
پروژه: ${GITHUB_REPOSITORY}
|
||||||
|
تگ: ${TAG:-${GITHUB_REF_NAME}}"
|
||||||
|
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
|
||||||
|
text="${text}
|
||||||
|
image: ${REG}/montact-api:${TAG}
|
||||||
|
image: ${REG}/montact-web:${TAG}
|
||||||
|
image: ${REG}/montact-stt:${TAG}"
|
||||||
|
fi
|
||||||
|
[ -n "$failed_step" ] && text="${text}
|
||||||
|
مرحلهٔ خطا: ${failed_step}"
|
||||||
|
text="${text}
|
||||||
|
کامیت: ${commit}
|
||||||
|
توسط: ${GITHUB_ACTOR}
|
||||||
|
مدت: $((elapsed / 60))m$((elapsed % 60))s
|
||||||
|
لاگ: ${RUN_URL}"
|
||||||
|
msg_file=$(mktemp)
|
||||||
|
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
|
||||||
|
|
||||||
|
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
|
||||||
|
--data-urlencode "text@${msg_file}"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# NestJS API + Prisma. Build context is the monorepo root.
|
||||||
|
FROM node:20-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/api/package.json apps/api/package.json
|
||||||
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
RUN npm run build -w @mento/shared \
|
||||||
|
&& npm run prisma:generate -w @mento/api \
|
||||||
|
&& npm run build -w @mento/api
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/packages/shared ./packages/shared
|
||||||
|
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||||
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||||
|
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/src/main.js"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Local Faster-Whisper. Not published on Ingress.
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY apps/stt/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY apps/stt/main.py ./main.py
|
||||||
|
ENV STT_MODEL=small \
|
||||||
|
STT_DEVICE=cpu \
|
||||||
|
STT_COMPUTE_TYPE=int8 \
|
||||||
|
HF_HOME=/models
|
||||||
|
EXPOSE 3040
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
|
||||||
|
FROM node:20-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/api/package.json apps/api/package.json
|
||||||
|
COPY apps/web/package.json apps/web/package.json
|
||||||
|
COPY packages/shared/package.json packages/shared/package.json
|
||||||
|
RUN npm ci
|
||||||
|
COPY apps/web apps/web
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
RUN npm run build -w @mento/shared && npm run build -w @mento/web
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
|
||||||
|
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
|
||||||
|
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
|
||||||
|
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
|
||||||
|
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
|
||||||
@@ -33,6 +33,17 @@ enum ActivityStatus {
|
|||||||
canceled
|
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')
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
|
||||||
|
|
||||||
|
export type InvoicePartyPosition = {
|
||||||
|
key: string;
|
||||||
|
partyKind: 'person' | 'company';
|
||||||
|
partyName: string;
|
||||||
|
/** فاکتورهای عادی بهعلاوهٔ ماندهٔ اول دورهٔ مثبت */
|
||||||
|
billed: Prisma.Decimal;
|
||||||
|
/** برگشتیها و اضافهدریافت، برای ستون تسویهشدهٔ بدهکار */
|
||||||
|
offset: Prisma.Decimal;
|
||||||
|
/** مثبت = طلب ما، منفی = بدهی ما */
|
||||||
|
net: Prisma.Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
key: string;
|
||||||
|
partyKind: 'person' | 'company';
|
||||||
|
partyName: string;
|
||||||
|
sales: Prisma.Decimal;
|
||||||
|
credits: Prisma.Decimal;
|
||||||
|
paid: Prisma.Decimal;
|
||||||
|
opening: Prisma.Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
|
function zero() {
|
||||||
|
return new Prisma.Decimal(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(value: Prisma.Decimal) {
|
||||||
|
return value.toDecimalPlaces(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همان فرمول ماندهٔ فاکتور: جمع ردیفها − تخفیف + مالیات، برگشتی منفی. */
|
||||||
|
export function signedInvoicePayable(invoice: {
|
||||||
|
kind: string;
|
||||||
|
discountAmount: Prisma.Decimal;
|
||||||
|
taxPercent: Prisma.Decimal;
|
||||||
|
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
|
||||||
|
}) {
|
||||||
|
const subtotal = invoice.lineItems.reduce(
|
||||||
|
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
|
||||||
|
zero(),
|
||||||
|
);
|
||||||
|
const discount = invoice.discountAmount ?? zero();
|
||||||
|
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
|
||||||
|
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
|
||||||
|
const signed = round2(subtotal.minus(discount).plus(tax));
|
||||||
|
return invoice.kind === 'credit' ? signed.neg() : signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
|
||||||
|
const connections = await prisma.accountingConnection.count({
|
||||||
|
where: { organizationId, enabled: true },
|
||||||
|
});
|
||||||
|
return connections === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ماندهٔ طرفحساب از فاکتور صادرشده و ماندهٔ اول دوره.
|
||||||
|
* سند درآمد جدا ساخته نمیشود تا همان فروش دو بار طلب نشود.
|
||||||
|
* اگر حسابداری بیرونی وصل باشد، خالی برمیگردد.
|
||||||
|
*/
|
||||||
|
export async function loadInvoicePartyPositions(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<InvoicePartyPosition[]> {
|
||||||
|
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
|
||||||
|
|
||||||
|
const [invoices, openings] = await Promise.all([
|
||||||
|
prisma.invoice.findMany({
|
||||||
|
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||||
|
select: {
|
||||||
|
kind: true,
|
||||||
|
discountAmount: true,
|
||||||
|
taxPercent: true,
|
||||||
|
personId: true,
|
||||||
|
companyId: true,
|
||||||
|
person: { select: { firstName: true, lastName: true } },
|
||||||
|
company: { select: { name: true } },
|
||||||
|
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||||
|
payments: { select: { amount: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.partyOpeningBalance.findMany({
|
||||||
|
where: { organizationId },
|
||||||
|
select: {
|
||||||
|
personId: true,
|
||||||
|
amount: true,
|
||||||
|
person: { select: { firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
const ensure = (
|
||||||
|
key: string,
|
||||||
|
partyKind: 'person' | 'company',
|
||||||
|
partyName: string,
|
||||||
|
) => {
|
||||||
|
const current = buckets.get(key);
|
||||||
|
if (current) return current;
|
||||||
|
const created: Bucket = {
|
||||||
|
key,
|
||||||
|
partyKind,
|
||||||
|
partyName,
|
||||||
|
sales: zero(),
|
||||||
|
credits: zero(),
|
||||||
|
paid: zero(),
|
||||||
|
opening: zero(),
|
||||||
|
};
|
||||||
|
buckets.set(key, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const personId = invoice.personId;
|
||||||
|
const companyId = invoice.companyId;
|
||||||
|
const party = personId
|
||||||
|
? {
|
||||||
|
key: `person:${personId}`,
|
||||||
|
partyKind: 'person' as const,
|
||||||
|
partyName:
|
||||||
|
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
|
||||||
|
'مشتری',
|
||||||
|
}
|
||||||
|
: companyId
|
||||||
|
? {
|
||||||
|
key: `company:${companyId}`,
|
||||||
|
partyKind: 'company' as const,
|
||||||
|
partyName: invoice.company?.name?.trim() || 'شرکت',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!party) continue;
|
||||||
|
const bucket = ensure(party.key, party.partyKind, party.partyName);
|
||||||
|
const payable = signedInvoicePayable(invoice);
|
||||||
|
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
|
||||||
|
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
|
||||||
|
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
|
||||||
|
bucket.paid = bucket.paid.plus(paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opening of openings) {
|
||||||
|
if (opening.amount.equals(0)) continue;
|
||||||
|
const bucket = ensure(
|
||||||
|
`person:${opening.personId}`,
|
||||||
|
'person',
|
||||||
|
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
|
||||||
|
);
|
||||||
|
bucket.opening = bucket.opening.plus(opening.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: InvoicePartyPosition[] = [];
|
||||||
|
for (const bucket of buckets.values()) {
|
||||||
|
const net = round2(
|
||||||
|
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
|
||||||
|
);
|
||||||
|
if (net.equals(0)) continue;
|
||||||
|
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
|
||||||
|
const offset = round2(billed.minus(net));
|
||||||
|
positions.push({
|
||||||
|
key: bucket.key,
|
||||||
|
partyKind: bucket.partyKind,
|
||||||
|
partyName: bucket.partyName,
|
||||||
|
billed,
|
||||||
|
offset: Prisma.Decimal.max(offset, 0),
|
||||||
|
net,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمیکند. */
|
||||||
|
export async function invoiceSalesInPeriod(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
from: Date | null,
|
||||||
|
to: Date | null,
|
||||||
|
) {
|
||||||
|
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
|
||||||
|
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
|
||||||
|
|
||||||
|
const invoices = await prisma.invoice.findMany({
|
||||||
|
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
|
||||||
|
select: {
|
||||||
|
kind: true,
|
||||||
|
discountAmount: true,
|
||||||
|
taxPercent: true,
|
||||||
|
issuedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
lineItems: { select: { quantity: true, unitPrice: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let sales = zero();
|
||||||
|
let credits = zero();
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const when = invoice.issuedAt ?? invoice.createdAt;
|
||||||
|
if (from && when < from) continue;
|
||||||
|
if (to && when > to) continue;
|
||||||
|
const payable = signedInvoicePayable(invoice);
|
||||||
|
if (payable.greaterThan(0)) sales = sales.plus(payable);
|
||||||
|
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sales: round2(sales).toFixed(2),
|
||||||
|
credits: round2(credits).toFixed(2),
|
||||||
|
net: round2(sales.minus(credits)).toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,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' {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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' }"
|
||||||
|
|||||||
+741
-142
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
مشاهده شخص
|
مشاهده شخص
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user