Compare commits
9
Commits
dev
...
6ebd45f2cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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");
|
||||||
@@ -2953,18 +2953,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 +2982,7 @@ model VoipConnection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
|
@@index([organizationId, provider])
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoipCallLog {
|
model VoipCallLog {
|
||||||
@@ -2986,6 +2997,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 +3023,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])
|
||||||
|
|||||||
@@ -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,10 +171,15 @@ 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,
|
organizationId: actor.organizationId,
|
||||||
...leadAccessWhere(actor),
|
...leadAccessWhere(actor),
|
||||||
archivedAt: opts.archived ? { not: null } : null,
|
archivedAt: opts.archived ? { not: null } : null,
|
||||||
|
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
||||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
...(opts.unassigned ? { assigneeId: null } : {}),
|
||||||
@@ -180,6 +195,52 @@ export class LeadsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
|
||||||
|
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 } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
opts: {
|
opts: {
|
||||||
@@ -194,8 +255,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 +308,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> {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -36,9 +37,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) } },
|
||||||
@@ -88,7 +95,13 @@ export class TreasuryOpsService {
|
|||||||
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 ?? '');
|
||||||
@@ -327,6 +340,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 +426,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)),
|
||||||
@@ -480,7 +500,13 @@ export class TreasuryOpsService {
|
|||||||
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;
|
||||||
|
occurredAt: Date;
|
||||||
|
account?: { name: string } | null;
|
||||||
|
}>;
|
||||||
},
|
},
|
||||||
fullName: string,
|
fullName: string,
|
||||||
) {
|
) {
|
||||||
@@ -500,6 +526,14 @@ export class TreasuryOpsService {
|
|||||||
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(),
|
||||||
|
accountName: item.account?.name ?? '',
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
|
||||||
@@ -753,7 +754,34 @@ export class TreasuryService {
|
|||||||
current.remaining = current.remaining.plus(rawRemaining);
|
current.remaining = current.remaining.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),
|
||||||
|
};
|
||||||
|
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,
|
||||||
@@ -1092,6 +1120,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 +1140,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),
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ export const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
roles: ['owner', 'admin'] as UserRole[],
|
roles: ['owner', 'admin'] as UserRole[],
|
||||||
treasuryTitle: 'حقوق و دستمزد',
|
treasuryTitle: 'حقوق و دستمزد',
|
||||||
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
|
treasuryDescription:
|
||||||
|
'تعیین حقوق، پرداخت یکجا یا چندمرحلهای، و گزارش دورهها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -350,7 +351,8 @@ export const router = createRouter({
|
|||||||
meta: {
|
meta: {
|
||||||
roles: ['owner', 'admin'] as UserRole[],
|
roles: ['owner', 'admin'] as UserRole[],
|
||||||
treasuryTitle: 'گزارش مالی',
|
treasuryTitle: 'گزارش مالی',
|
||||||
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.',
|
treasuryDescription:
|
||||||
|
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یکجا نشان میدهند.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+495
-136
@@ -5,10 +5,12 @@ import axios from 'axios';
|
|||||||
import {
|
import {
|
||||||
Alert as AAlert,
|
Alert as AAlert,
|
||||||
Avatar as AAvatar,
|
Avatar as AAvatar,
|
||||||
|
Badge as ABadge,
|
||||||
Button as AButton,
|
Button as AButton,
|
||||||
Card as ACard,
|
Card as ACard,
|
||||||
Checkbox as ACheckbox,
|
Checkbox as ACheckbox,
|
||||||
Col as ACol,
|
Col as ACol,
|
||||||
|
Drawer as ADrawer,
|
||||||
Dropdown as ADropdown,
|
Dropdown as ADropdown,
|
||||||
Empty as AEmpty,
|
Empty as AEmpty,
|
||||||
Flex as AFlex,
|
Flex as AFlex,
|
||||||
@@ -48,7 +50,6 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
UserOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +62,7 @@ import { canManageSettings } from '@/lib/roles';
|
|||||||
import { canAssignToOthers } from '@/lib/assign-access';
|
import { canAssignToOthers } from '@/lib/assign-access';
|
||||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||||
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||||
import {
|
import {
|
||||||
@@ -92,8 +94,25 @@ const isMobile = computed(() => !screens.value.md);
|
|||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
||||||
|
type FollowUpKey =
|
||||||
|
| ''
|
||||||
|
| 'due_today'
|
||||||
|
| 'overdue'
|
||||||
|
| 'called_today'
|
||||||
|
| 'missed_today'
|
||||||
|
| 'no_follow_up';
|
||||||
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
|
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
|
||||||
|
|
||||||
|
const FOLLOW_UP_OPTIONS: Array<{ value: FollowUpKey; label: string }> = [
|
||||||
|
{ value: '', label: 'همه پیگیریها' },
|
||||||
|
{ value: 'due_today', label: 'قرار امروز' },
|
||||||
|
{ value: 'overdue', label: 'عقبافتاده' },
|
||||||
|
{ value: 'called_today', label: 'امروز تماس گرفته' },
|
||||||
|
{ value: 'missed_today', label: 'امروز باید میگرفت و نگرفته' },
|
||||||
|
{ value: 'no_follow_up', label: 'بدون پیگیری' },
|
||||||
|
];
|
||||||
|
const UNASSIGNED_VALUE = '__unassigned__';
|
||||||
|
|
||||||
/** ردیف آمار صفحه لیدها — ترتیب و نامها مطابق طرح */
|
/** ردیف آمار صفحه لیدها — ترتیب و نامها مطابق طرح */
|
||||||
const LEAD_STAT_DEFS: Array<{
|
const LEAD_STAT_DEFS: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -162,8 +181,11 @@ const stages = ref<LeadStageDto[]>([]);
|
|||||||
const stats = ref<LeadStatsDto | null>(null);
|
const stats = ref<LeadStatsDto | null>(null);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const cityOptions = ref<string[]>([]);
|
const cityOptions = ref<string[]>([]);
|
||||||
|
const regionOptions = ref<string[]>([]);
|
||||||
|
const tradeOptions = ref<string[]>([]);
|
||||||
const sourceOptions = ref<string[]>([]);
|
const sourceOptions = ref<string[]>([]);
|
||||||
const selected = ref<Set<string>>(new Set());
|
const selected = ref<Set<string>>(new Set());
|
||||||
|
const moreFiltersOpen = ref(false);
|
||||||
const sortOpen = ref(false);
|
const sortOpen = ref(false);
|
||||||
const rowMenuId = ref<string | null>(null);
|
const rowMenuId = ref<string | null>(null);
|
||||||
const logoMenuId = ref<string | null>(null);
|
const logoMenuId = ref<string | null>(null);
|
||||||
@@ -235,12 +257,70 @@ const assigneeId = computed(() =>
|
|||||||
const cityFilter = computed(() =>
|
const cityFilter = computed(() =>
|
||||||
typeof route.query.city === 'string' ? route.query.city : '',
|
typeof route.query.city === 'string' ? route.query.city : '',
|
||||||
);
|
);
|
||||||
|
const regionFilter = computed(() =>
|
||||||
|
typeof route.query.region === 'string' ? route.query.region : '',
|
||||||
|
);
|
||||||
|
const tradeFilter = computed(() =>
|
||||||
|
typeof route.query.trade === 'string' ? route.query.trade : '',
|
||||||
|
);
|
||||||
const sourceFilter = computed(() =>
|
const sourceFilter = computed(() =>
|
||||||
typeof route.query.source === 'string' ? route.query.source : '',
|
typeof route.query.source === 'string' ? route.query.source : '',
|
||||||
);
|
);
|
||||||
|
const followUpFilter = computed<FollowUpKey>(() => {
|
||||||
|
const value = route.query.followUp;
|
||||||
|
if (
|
||||||
|
value === 'due_today' ||
|
||||||
|
value === 'overdue' ||
|
||||||
|
value === 'called_today' ||
|
||||||
|
value === 'missed_today' ||
|
||||||
|
value === 'no_follow_up'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const createdFromFilter = computed(() =>
|
||||||
|
typeof route.query.createdFrom === 'string' ? route.query.createdFrom : '',
|
||||||
|
);
|
||||||
|
const createdToFilter = computed(() =>
|
||||||
|
typeof route.query.createdTo === 'string' ? route.query.createdTo : '',
|
||||||
|
);
|
||||||
const unassigned = computed(
|
const unassigned = computed(
|
||||||
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
||||||
);
|
);
|
||||||
|
const archivedFilter = computed(
|
||||||
|
() => route.query.archived === '1' || route.query.archived === 'true',
|
||||||
|
);
|
||||||
|
const convertedFilter = computed(
|
||||||
|
() => route.query.converted === '1' || route.query.converted === 'true',
|
||||||
|
);
|
||||||
|
const assigneeSelectValue = computed(() => {
|
||||||
|
if (unassigned.value) return UNASSIGNED_VALUE;
|
||||||
|
return assigneeId.value || undefined;
|
||||||
|
});
|
||||||
|
const moreFiltersCount = computed(() =>
|
||||||
|
[
|
||||||
|
cityFilter.value,
|
||||||
|
regionFilter.value,
|
||||||
|
tradeFilter.value,
|
||||||
|
sourceFilter.value,
|
||||||
|
createdFromFilter.value,
|
||||||
|
createdToFilter.value,
|
||||||
|
archivedFilter.value ? '1' : '',
|
||||||
|
convertedFilter.value ? '1' : '',
|
||||||
|
].filter(Boolean).length,
|
||||||
|
);
|
||||||
|
/** در موبایل مرتبسازی و مسئول و پیگیری هم داخل کشو هستند. */
|
||||||
|
const mobileFilterBadgeCount = computed(() => {
|
||||||
|
const extra = isMobile.value
|
||||||
|
? [
|
||||||
|
sort.value !== 'updated' ? '1' : '',
|
||||||
|
assigneeSelectValue.value ? '1' : '',
|
||||||
|
followUpFilter.value,
|
||||||
|
].filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
return moreFiltersCount.value + extra;
|
||||||
|
});
|
||||||
|
|
||||||
const total = computed(() => data.value?.total ?? 0);
|
const total = computed(() => data.value?.total ?? 0);
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||||
@@ -514,14 +594,79 @@ function setStageFilter(id: string) {
|
|||||||
patchQuery({ stageId: id || undefined, page: undefined });
|
patchQuery({ stageId: id || undefined, page: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAssigneeFilter(value: string | undefined) {
|
||||||
|
if (value === UNASSIGNED_VALUE) {
|
||||||
|
patchQuery({ assigneeId: undefined, unassigned: '1', page: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patchQuery({
|
||||||
|
assigneeId: value || undefined,
|
||||||
|
unassigned: undefined,
|
||||||
|
page: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFollowUpFilter(value: string | undefined) {
|
||||||
|
patchQuery({
|
||||||
|
followUp: value || undefined,
|
||||||
|
page: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setCityFilter(value: string) {
|
function setCityFilter(value: string) {
|
||||||
patchQuery({ city: value || undefined, page: undefined });
|
patchQuery({ city: value || undefined, page: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRegionFilter(value: string) {
|
||||||
|
patchQuery({ region: value || undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTradeFilter(value: string) {
|
||||||
|
patchQuery({ trade: value || undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
function setSourceFilter(value: string) {
|
function setSourceFilter(value: string) {
|
||||||
patchQuery({ source: value || undefined, page: undefined });
|
patchQuery({ source: value || undefined, page: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCreatedFromFilter(value: string) {
|
||||||
|
patchQuery({ createdFrom: value || undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCreatedToFilter(value: string) {
|
||||||
|
patchQuery({ createdTo: value || undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setArchivedFilter(checked: boolean) {
|
||||||
|
patchQuery({ archived: checked ? '1' : undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConvertedFilter(checked: boolean) {
|
||||||
|
patchQuery({ converted: checked ? '1' : undefined, page: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMoreFilters() {
|
||||||
|
patchQuery({
|
||||||
|
city: undefined,
|
||||||
|
region: undefined,
|
||||||
|
trade: undefined,
|
||||||
|
source: undefined,
|
||||||
|
createdFrom: undefined,
|
||||||
|
createdTo: undefined,
|
||||||
|
archived: undefined,
|
||||||
|
converted: undefined,
|
||||||
|
...(isMobile.value
|
||||||
|
? {
|
||||||
|
sort: undefined,
|
||||||
|
assigneeId: undefined,
|
||||||
|
unassigned: undefined,
|
||||||
|
followUp: undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
page: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setPage(next: number) {
|
function setPage(next: number) {
|
||||||
if (next < 1 || next > pageCount.value) return;
|
if (next < 1 || next > pageCount.value) return;
|
||||||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||||||
@@ -594,12 +739,18 @@ function onCardClick(lead: LeadDto) {
|
|||||||
|
|
||||||
function mergeFacets(items: LeadDto[]) {
|
function mergeFacets(items: LeadDto[]) {
|
||||||
const cities = new Set(cityOptions.value);
|
const cities = new Set(cityOptions.value);
|
||||||
|
const regions = new Set(regionOptions.value);
|
||||||
|
const trades = new Set(tradeOptions.value);
|
||||||
const sources = new Set(sourceOptions.value);
|
const sources = new Set(sourceOptions.value);
|
||||||
for (const lead of items) {
|
for (const lead of items) {
|
||||||
if (lead.city?.trim()) cities.add(lead.city.trim());
|
if (lead.city?.trim()) cities.add(lead.city.trim());
|
||||||
|
if (lead.region?.trim()) regions.add(lead.region.trim());
|
||||||
|
if (lead.trade?.trim()) trades.add(lead.trade.trim());
|
||||||
if (lead.source?.trim()) sources.add(lead.source.trim());
|
if (lead.source?.trim()) sources.add(lead.source.trim());
|
||||||
}
|
}
|
||||||
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa'));
|
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa'));
|
||||||
|
regionOptions.value = [...regions].sort((a, b) => a.localeCompare(b, 'fa'));
|
||||||
|
tradeOptions.value = [...trades].sort((a, b) => a.localeCompare(b, 'fa'));
|
||||||
sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
|
sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,8 +782,15 @@ async function load() {
|
|||||||
stageId: stageId.value || undefined,
|
stageId: stageId.value || undefined,
|
||||||
assigneeId: assigneeId.value || undefined,
|
assigneeId: assigneeId.value || undefined,
|
||||||
city: cityFilter.value || undefined,
|
city: cityFilter.value || undefined,
|
||||||
|
region: regionFilter.value || undefined,
|
||||||
|
trade: tradeFilter.value || undefined,
|
||||||
source: sourceFilter.value || undefined,
|
source: sourceFilter.value || undefined,
|
||||||
unassigned: unassigned.value ? '1' : undefined,
|
unassigned: unassigned.value ? '1' : undefined,
|
||||||
|
archived: archivedFilter.value ? '1' : undefined,
|
||||||
|
converted: convertedFilter.value ? '1' : undefined,
|
||||||
|
followUp: followUpFilter.value || undefined,
|
||||||
|
createdFrom: createdFromFilter.value || undefined,
|
||||||
|
createdTo: createdToFilter.value || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
data.value = res;
|
data.value = res;
|
||||||
@@ -1114,8 +1272,15 @@ watch(
|
|||||||
route.query.stageId,
|
route.query.stageId,
|
||||||
route.query.assigneeId,
|
route.query.assigneeId,
|
||||||
route.query.city,
|
route.query.city,
|
||||||
|
route.query.region,
|
||||||
|
route.query.trade,
|
||||||
route.query.source,
|
route.query.source,
|
||||||
route.query.unassigned,
|
route.query.unassigned,
|
||||||
|
route.query.archived,
|
||||||
|
route.query.converted,
|
||||||
|
route.query.followUp,
|
||||||
|
route.query.createdFrom,
|
||||||
|
route.query.createdTo,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||||
@@ -1181,176 +1346,370 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-row :gutter="[24, 24]" @click="closePopups">
|
<a-row :gutter="[16, 16]" @click="closePopups">
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
||||||
<div>
|
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
||||||
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
|
||||||
<a-typography-text type="secondary">
|
|
||||||
مدیریت و پیگیری تمام لیدهای ورودی
|
|
||||||
</a-typography-text>
|
|
||||||
</div>
|
|
||||||
<a-space :size="8" wrap>
|
<a-space :size="8" wrap>
|
||||||
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
|
<template v-if="isMobile">
|
||||||
<template #icon><ExportOutlined /></template>
|
<a-dropdown :trigger="['click']" placement="bottomLeft">
|
||||||
خروجی اکسل
|
<a-button :disabled="busy">
|
||||||
</a-button>
|
<template #icon><MoreOutlined /></template>
|
||||||
<ExcelImportFlow
|
بیشتر
|
||||||
preview-path="/leads/import/preview"
|
|
||||||
import-path="/leads/import/file"
|
|
||||||
:disabled="busy"
|
|
||||||
@start="onExcelImportStart"
|
|
||||||
@done="onExcelImportDone"
|
|
||||||
@fail="onExcelImportFail"
|
|
||||||
@settled="onExcelImportSettled"
|
|
||||||
>
|
|
||||||
<template #default="{ loading: excelLoading }">
|
|
||||||
<a-button :disabled="busy" :loading="excelLoading">
|
|
||||||
<template #icon><FileExcelOutlined /></template>
|
|
||||||
ورود از اکسل
|
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
<template #overlay>
|
||||||
</ExcelImportFlow>
|
<a-menu>
|
||||||
<PhoneContactsImport
|
<a-menu-item
|
||||||
target="leads"
|
key="export"
|
||||||
:disabled="busy"
|
:disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
|
||||||
@start="onPhoneImportStart"
|
@click="exportExcel"
|
||||||
@done="onPhoneImportDone"
|
>
|
||||||
@fail="onPhoneImportFail"
|
<ExportOutlined />
|
||||||
@settled="onPhoneImportSettled"
|
خروجی اکسل
|
||||||
>
|
</a-menu-item>
|
||||||
<a-button :disabled="busy">
|
<a-menu-item key="excel" :disabled="busy">
|
||||||
<template #icon><PhoneOutlined /></template>
|
<ExcelImportFlow
|
||||||
از مخاطبین گوشی
|
preview-path="/leads/import/preview"
|
||||||
|
import-path="/leads/import/file"
|
||||||
|
:disabled="busy"
|
||||||
|
@start="onExcelImportStart"
|
||||||
|
@done="onExcelImportDone"
|
||||||
|
@fail="onExcelImportFail"
|
||||||
|
@settled="onExcelImportSettled"
|
||||||
|
>
|
||||||
|
<template #default="{ loading: excelLoading }">
|
||||||
|
<a-flex align="center" :gap="8" :style="{ opacity: excelLoading ? 0.6 : 1 }">
|
||||||
|
<FileExcelOutlined />
|
||||||
|
ورود از اکسل
|
||||||
|
</a-flex>
|
||||||
|
</template>
|
||||||
|
</ExcelImportFlow>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="phone" :disabled="busy">
|
||||||
|
<PhoneContactsImport
|
||||||
|
target="leads"
|
||||||
|
:disabled="busy"
|
||||||
|
@start="onPhoneImportStart"
|
||||||
|
@done="onPhoneImportDone"
|
||||||
|
@fail="onPhoneImportFail"
|
||||||
|
@settled="onPhoneImportSettled"
|
||||||
|
>
|
||||||
|
<a-flex align="center" :gap="8">
|
||||||
|
<PhoneOutlined />
|
||||||
|
از مخاطبین گوشی
|
||||||
|
</a-flex>
|
||||||
|
</PhoneContactsImport>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
|
||||||
|
<template #icon><ExportOutlined /></template>
|
||||||
|
خروجی اکسل
|
||||||
</a-button>
|
</a-button>
|
||||||
</PhoneContactsImport>
|
<ExcelImportFlow
|
||||||
|
preview-path="/leads/import/preview"
|
||||||
|
import-path="/leads/import/file"
|
||||||
|
:disabled="busy"
|
||||||
|
@start="onExcelImportStart"
|
||||||
|
@done="onExcelImportDone"
|
||||||
|
@fail="onExcelImportFail"
|
||||||
|
@settled="onExcelImportSettled"
|
||||||
|
>
|
||||||
|
<template #default="{ loading: excelLoading }">
|
||||||
|
<a-button :disabled="busy" :loading="excelLoading">
|
||||||
|
<template #icon><FileExcelOutlined /></template>
|
||||||
|
ورود از اکسل
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</ExcelImportFlow>
|
||||||
|
<PhoneContactsImport
|
||||||
|
target="leads"
|
||||||
|
:disabled="busy"
|
||||||
|
@start="onPhoneImportStart"
|
||||||
|
@done="onPhoneImportDone"
|
||||||
|
@fail="onPhoneImportFail"
|
||||||
|
@settled="onPhoneImportSettled"
|
||||||
|
>
|
||||||
|
<a-button :disabled="busy">
|
||||||
|
<template #icon><PhoneOutlined /></template>
|
||||||
|
از مخاطبین گوشی
|
||||||
|
</a-button>
|
||||||
|
</PhoneContactsImport>
|
||||||
|
</template>
|
||||||
|
<a-radio-group
|
||||||
|
v-if="isMobile"
|
||||||
|
value="list"
|
||||||
|
button-style="solid"
|
||||||
|
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
|
||||||
|
>
|
||||||
|
<a-radio-button value="list">
|
||||||
|
<UnorderedListOutlined />
|
||||||
|
</a-radio-button>
|
||||||
|
<a-radio-button value="board">
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
<a-button type="primary" @click.stop="openCreate">
|
<a-button type="primary" @click.stop="openCreate">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
لید جدید
|
{{ isMobile ? 'جدید' : 'لید جدید' }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
|
||||||
<a-row :gutter="[12, 12]">
|
|
||||||
<a-col :xs="12" :sm="8" :md="4">
|
|
||||||
<a-card
|
|
||||||
:bordered="true"
|
|
||||||
hoverable
|
|
||||||
:style="{
|
|
||||||
borderColor: !stageId ? token.colorPrimary : undefined,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}"
|
|
||||||
@click="setStageFilter('')"
|
|
||||||
>
|
|
||||||
<a-flex align="center" :gap="12">
|
|
||||||
<a-avatar :style="{ background: token.colorPrimaryBg, color: token.colorPrimary }">
|
|
||||||
<template #icon><UserOutlined /></template>
|
|
||||||
</a-avatar>
|
|
||||||
<div>
|
|
||||||
<a-typography-text type="secondary">همه لیدها</a-typography-text>
|
|
||||||
<div>
|
|
||||||
<a-typography-text strong>{{ formatCount(totalLeadsCount) }}</a-typography-text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-flex>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col
|
|
||||||
v-for="card in stageCards"
|
|
||||||
:key="card.key"
|
|
||||||
:xs="12"
|
|
||||||
:sm="8"
|
|
||||||
:md="4"
|
|
||||||
>
|
|
||||||
<a-card
|
|
||||||
:bordered="true"
|
|
||||||
hoverable
|
|
||||||
:style="{
|
|
||||||
borderColor: isStatActive(card) ? token.colorPrimary : undefined,
|
|
||||||
cursor: card.stageId ? 'pointer' : 'not-allowed',
|
|
||||||
opacity: card.stageId ? 1 : 0.55,
|
|
||||||
}"
|
|
||||||
@click="card.stageId && selectStatCard(card)"
|
|
||||||
>
|
|
||||||
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
|
|
||||||
<div>
|
|
||||||
<a-typography-text strong>{{ formatCount(card.count) }}</a-typography-text>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-col>
|
|
||||||
|
|
||||||
<a-col :span="24" @click.stop>
|
<a-col :span="24" @click.stop>
|
||||||
<a-card :bordered="true">
|
<a-card :bordered="true">
|
||||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
<a-flex vertical :gap="12">
|
||||||
<a-flex align="center" wrap="wrap" :gap="8" flex="1 1 auto">
|
<a-flex v-if="!isMobile" justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||||
|
<a-flex wrap="wrap" :gap="8" align="center" flex="1 1 auto">
|
||||||
|
<a-button :type="!stageId ? 'primary' : 'default'" @click="setStageFilter('')">
|
||||||
|
همه لیدها
|
||||||
|
<a-typography-text
|
||||||
|
:type="!stageId ? undefined : 'secondary'"
|
||||||
|
:style="{ color: !stageId ? 'inherit' : undefined, marginInlineStart: '6px' }"
|
||||||
|
>
|
||||||
|
{{ formatCount(totalLeadsCount) }}
|
||||||
|
</a-typography-text>
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-for="card in stageCards"
|
||||||
|
:key="card.key"
|
||||||
|
:type="isStatActive(card) ? 'primary' : 'default'"
|
||||||
|
:disabled="!card.stageId"
|
||||||
|
@click="selectStatCard(card)"
|
||||||
|
>
|
||||||
|
{{ card.label }}
|
||||||
|
<a-typography-text
|
||||||
|
:type="isStatActive(card) ? undefined : 'secondary'"
|
||||||
|
:style="{ color: isStatActive(card) ? 'inherit' : undefined, marginInlineStart: '6px' }"
|
||||||
|
>
|
||||||
|
{{ formatCount(card.count) }}
|
||||||
|
</a-typography-text>
|
||||||
|
</a-button>
|
||||||
|
</a-flex>
|
||||||
<a-space :size="8">
|
<a-space :size="8">
|
||||||
<FilterOutlined />
|
<TableColumnsMenu table-key="leads" align="end" />
|
||||||
<a-typography-text strong>فیلترها</a-typography-text>
|
<a-radio-group
|
||||||
|
value="list"
|
||||||
|
button-style="solid"
|
||||||
|
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
|
||||||
|
>
|
||||||
|
<a-radio-button value="list">
|
||||||
|
<UnorderedListOutlined />
|
||||||
|
</a-radio-button>
|
||||||
|
<a-radio-button value="board">
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
</a-space>
|
</a-space>
|
||||||
<a-select
|
</a-flex>
|
||||||
:value="sort"
|
|
||||||
style="width: 11rem"
|
<a-flex v-if="isMobile" vertical :gap="8">
|
||||||
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
|
||||||
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
|
||||||
/>
|
|
||||||
<a-input-search
|
<a-input-search
|
||||||
v-model:value="q"
|
v-model:value="q"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="جستجو..."
|
placeholder="جستجو نام، موبایل، شهر…"
|
||||||
enter-button="جستجو"
|
style="width: 100%"
|
||||||
style="width: 16rem; max-width: 100%"
|
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
>
|
>
|
||||||
<template #prefix><SearchOutlined /></template>
|
<template #enterButton>
|
||||||
|
<a-button type="primary">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
</a-input-search>
|
</a-input-search>
|
||||||
<a-select
|
<a-badge
|
||||||
:value="cityFilter || undefined"
|
:count="mobileFilterBadgeCount"
|
||||||
|
:offset="[-2, 2]"
|
||||||
|
:style="{ width: '100%', display: 'block' }"
|
||||||
|
>
|
||||||
|
<a-button block @click="moreFiltersOpen = true">
|
||||||
|
<template #icon><FilterOutlined /></template>
|
||||||
|
فیلترها
|
||||||
|
</a-button>
|
||||||
|
</a-badge>
|
||||||
|
</a-flex>
|
||||||
|
<a-flex v-else align="center" wrap="wrap" :gap="8">
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="q"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="همه مناطق"
|
placeholder="جستجو نام، موبایل، شهر…"
|
||||||
style="width: 9.5rem"
|
style="width: 15rem; max-width: 100%"
|
||||||
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
@search="onSearch"
|
||||||
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
>
|
||||||
|
<template #enterButton>
|
||||||
|
<a-button type="primary">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input-search>
|
||||||
|
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]">
|
||||||
|
<a-button @click="moreFiltersOpen = true">
|
||||||
|
<template #icon><FilterOutlined /></template>
|
||||||
|
فیلترها
|
||||||
|
</a-button>
|
||||||
|
</a-badge>
|
||||||
|
<a-select
|
||||||
|
:value="sort"
|
||||||
|
style="width: 10rem"
|
||||||
|
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||||
|
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||||
/>
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
:value="sourceFilter || undefined"
|
:value="assigneeSelectValue"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="همه منابع"
|
show-search
|
||||||
style="width: 9.5rem"
|
option-filter-prop="label"
|
||||||
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
placeholder="مسئول"
|
||||||
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
style="width: 10rem"
|
||||||
|
:options="[
|
||||||
|
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
||||||
|
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||||
|
]"
|
||||||
|
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
||||||
/>
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
:value="stageId || undefined"
|
:value="followUpFilter || undefined"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="همه وضعیت"
|
placeholder="پیگیری"
|
||||||
style="width: 9.5rem"
|
style="width: 13rem"
|
||||||
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
|
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
||||||
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
|
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
||||||
/>
|
/>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
<a-space :size="8">
|
|
||||||
<TableColumnsMenu table-key="leads" align="end" />
|
|
||||||
<a-radio-group
|
|
||||||
value="list"
|
|
||||||
button-style="solid"
|
|
||||||
@change="(e: any) => { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
|
|
||||||
>
|
|
||||||
<a-radio-button value="list">
|
|
||||||
<UnorderedListOutlined />
|
|
||||||
</a-radio-button>
|
|
||||||
<a-radio-button value="board">
|
|
||||||
<AppstoreOutlined />
|
|
||||||
</a-radio-button>
|
|
||||||
</a-radio-group>
|
|
||||||
</a-space>
|
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
|
<a-drawer
|
||||||
|
v-model:open="moreFiltersOpen"
|
||||||
|
:title="isMobile ? 'فیلترها' : 'فیلترهای بیشتر'"
|
||||||
|
placement="left"
|
||||||
|
:width="isMobile ? '100%' : 380"
|
||||||
|
:destroy-on-close="false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<a-form-item label="مرتبسازی">
|
||||||
|
<a-select
|
||||||
|
:value="sort"
|
||||||
|
style="width: 100%"
|
||||||
|
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||||
|
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="مسئول">
|
||||||
|
<a-select
|
||||||
|
:value="assigneeSelectValue"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
placeholder="همه مسئولان"
|
||||||
|
style="width: 100%"
|
||||||
|
:options="[
|
||||||
|
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
|
||||||
|
...users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||||
|
]"
|
||||||
|
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="پیگیری">
|
||||||
|
<a-select
|
||||||
|
:value="followUpFilter || undefined"
|
||||||
|
allow-clear
|
||||||
|
placeholder="همه پیگیریها"
|
||||||
|
style="width: 100%"
|
||||||
|
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
|
||||||
|
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="شهر">
|
||||||
|
<a-select
|
||||||
|
:value="cityFilter || undefined"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
placeholder="همه شهرها"
|
||||||
|
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
||||||
|
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="منطقه">
|
||||||
|
<a-select
|
||||||
|
:value="regionFilter || undefined"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
placeholder="همه مناطق"
|
||||||
|
:options="regionOptions.map((r) => ({ value: r, label: r }))"
|
||||||
|
@update:value="(v) => setRegionFilter(v == null ? '' : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="صنف">
|
||||||
|
<a-select
|
||||||
|
:value="tradeFilter || undefined"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
placeholder="همه اصناف"
|
||||||
|
:options="tradeOptions.map((t) => ({ value: t, label: t }))"
|
||||||
|
@update:value="(v) => setTradeFilter(v == null ? '' : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="منبع">
|
||||||
|
<a-select
|
||||||
|
:value="sourceFilter || undefined"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
placeholder="همه منابع"
|
||||||
|
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
||||||
|
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="از تاریخ ثبت">
|
||||||
|
<JalaliDateInput
|
||||||
|
:model-value="createdFromFilter"
|
||||||
|
placeholder="از تاریخ"
|
||||||
|
@update:model-value="setCreatedFromFilter"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="تا تاریخ ثبت">
|
||||||
|
<JalaliDateInput
|
||||||
|
:model-value="createdToFilter"
|
||||||
|
placeholder="تا تاریخ"
|
||||||
|
@update:model-value="setCreatedToFilter"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="convertedFilter"
|
||||||
|
@update:checked="setConvertedFilter"
|
||||||
|
>
|
||||||
|
فقط تبدیلشدهها
|
||||||
|
</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-checkbox
|
||||||
|
:checked="archivedFilter"
|
||||||
|
@update:checked="setArchivedFilter"
|
||||||
|
>
|
||||||
|
فقط بایگانیشدهها
|
||||||
|
</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<template #footer>
|
||||||
|
<a-space>
|
||||||
|
<a-button :disabled="!mobileFilterBadgeCount" @click="clearMoreFilters">
|
||||||
|
پاک کردن
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="moreFiltersOpen = false">
|
||||||
|
بستن
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
<a-col v-if="selectedIds.length" :span="24" @click.stop>
|
<a-col v-if="selectedIds.length" :span="24" @click.stop>
|
||||||
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
|
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
|
||||||
<a-space :size="8" wrap>
|
<a-space :size="8" wrap>
|
||||||
|
|||||||
@@ -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,36 +1901,6 @@ 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
Alert as AAlert,
|
Alert as AAlert,
|
||||||
Button as AButton,
|
Button as AButton,
|
||||||
Card as ACard,
|
Card as ACard,
|
||||||
Col as ACol,
|
Col as ACol,
|
||||||
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,
|
||||||
|
Table as ATable,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||||
import MoneyInput from '@/components/MoneyInput.vue';
|
import MoneyInput from '@/components/MoneyInput.vue';
|
||||||
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||||
import { toman } from './treasury-format';
|
import { 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 AListItemMeta = AList.Item.Meta;
|
type PayrollPayment = {
|
||||||
const ASelectOption = ASelect.Option;
|
id: string;
|
||||||
|
amount: string;
|
||||||
|
occurredAt: string;
|
||||||
|
accountName: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PayrollRow = {
|
type PayrollRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
period: string;
|
period: string;
|
||||||
|
baseAmount: string;
|
||||||
|
benefits: string;
|
||||||
|
bonus: string;
|
||||||
|
deductions: string;
|
||||||
payable: string;
|
payable: string;
|
||||||
|
paidAmount: string;
|
||||||
remaining: string;
|
remaining: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
payments: PayrollPayment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
open: 'پرداختنشده',
|
||||||
|
partial: 'پرداخت جزئی',
|
||||||
|
settled: 'تسویهشده',
|
||||||
|
void: 'باطل',
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = ref<PayrollRow[]>([]);
|
const rows = ref<PayrollRow[]>([]);
|
||||||
@@ -53,6 +72,85 @@ const form = ref({
|
|||||||
deductions: '',
|
deductions: '',
|
||||||
});
|
});
|
||||||
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
|
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
|
||||||
|
const filterUserId = ref<string | undefined>(undefined);
|
||||||
|
const filterPeriod = ref('');
|
||||||
|
const filterStatus = ref('all');
|
||||||
|
const filterMin = ref('');
|
||||||
|
const filterMax = ref('');
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'all', label: 'همه وضعیتها' },
|
||||||
|
{ value: 'open', label: 'پرداختنشده' },
|
||||||
|
{ value: 'partial', label: 'پرداخت جزئی' },
|
||||||
|
{ value: 'settled', label: 'تسویهشده' },
|
||||||
|
{ value: 'void', label: 'باطل' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
users.value.map((user) => ({ value: user.id, label: user.fullName })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payablePreview = computed(() => {
|
||||||
|
const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
|
||||||
|
const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0;
|
||||||
|
const bonus = parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0;
|
||||||
|
const deductions = parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0;
|
||||||
|
return base + benefits + bonus - deductions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openRows = computed(() =>
|
||||||
|
rows.value.filter((row) => row.status === 'open' || row.status === 'partial'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payOptions = computed(() =>
|
||||||
|
openRows.value.map((row) => ({
|
||||||
|
value: row.id,
|
||||||
|
label: `${row.fullName} · ${row.period} · مانده ${toman(row.remaining)}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null);
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const period = filterPeriod.value.trim();
|
||||||
|
const min = parseMoneyNumber(String(filterMin.value ?? ''), 0);
|
||||||
|
const max = parseMoneyNumber(String(filterMax.value ?? ''), 0);
|
||||||
|
return rows.value.filter((row) => {
|
||||||
|
if (filterUserId.value && row.userId !== filterUserId.value) return false;
|
||||||
|
if (period && !row.period.includes(period)) return false;
|
||||||
|
if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false;
|
||||||
|
const payable = Number(row.payable);
|
||||||
|
if (min != null && filterMin.value !== '' && payable < min) return false;
|
||||||
|
if (max != null && filterMax.value !== '' && payable > max) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const moneyKeys = ['baseAmount', 'benefits', 'bonus', 'deductions', 'payable', 'paidAmount', 'remaining'] as const;
|
||||||
|
|
||||||
|
const reportColumns: TableColumnsType<PayrollRow> = [
|
||||||
|
{ title: 'پرسنل', dataIndex: 'fullName', key: 'fullName' },
|
||||||
|
{ title: 'دوره', dataIndex: 'period', key: 'period' },
|
||||||
|
{ title: 'پایه', key: 'baseAmount' },
|
||||||
|
{ title: 'مزایا', key: 'benefits' },
|
||||||
|
{ title: 'پاداش', key: 'bonus' },
|
||||||
|
{ title: 'کسورات', key: 'deductions' },
|
||||||
|
{ title: 'قابل پرداخت', key: 'payable' },
|
||||||
|
{ title: 'پرداختشده', key: 'paidAmount' },
|
||||||
|
{ title: 'مانده', key: 'remaining' },
|
||||||
|
{ title: 'وضعیت', key: 'status' },
|
||||||
|
{ title: 'عملیات', key: 'action' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentColumns: TableColumnsType<PayrollPayment> = [
|
||||||
|
{
|
||||||
|
title: 'تاریخ',
|
||||||
|
key: 'occurredAt',
|
||||||
|
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
|
||||||
|
},
|
||||||
|
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
|
||||||
|
{ title: 'مبلغ', key: 'amount', customRender: ({ record }) => toman(record.amount) },
|
||||||
|
];
|
||||||
|
|
||||||
function apiMessage(e: unknown) {
|
function apiMessage(e: unknown) {
|
||||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||||
@@ -70,7 +168,7 @@ async function load() {
|
|||||||
api.get<OrgUserDto[]>('/users'),
|
api.get<OrgUserDto[]>('/users'),
|
||||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||||
]);
|
]);
|
||||||
rows.value = payroll.data;
|
rows.value = payroll.data.map((row) => ({ ...row, payments: row.payments ?? [] }));
|
||||||
users.value = people.data.filter((row) => row.isActive !== false);
|
users.value = people.data.filter((row) => row.isActive !== false);
|
||||||
accounts.value = acc.data.filter((row) => row.isActive);
|
accounts.value = acc.data.filter((row) => row.isActive);
|
||||||
}
|
}
|
||||||
@@ -97,7 +195,7 @@ async function createRow() {
|
|||||||
bonus: '',
|
bonus: '',
|
||||||
deductions: '',
|
deductions: '',
|
||||||
};
|
};
|
||||||
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمیکند.';
|
ok.value = 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمیشود.';
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiMessage(e);
|
error.value = apiMessage(e);
|
||||||
@@ -106,24 +204,44 @@ async function createRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPay(id: string) {
|
||||||
|
const row = rows.value.find((item) => item.id === id);
|
||||||
|
if (!row) {
|
||||||
|
pay.value = { id: '', amount: '', accountId: pay.value.accountId };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pay.value = {
|
||||||
|
id: row.id,
|
||||||
|
amount: row.remaining,
|
||||||
|
accountId: pay.value.accountId ?? accounts.value[0]?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function payRow() {
|
async function payRow() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
ok.value = '';
|
ok.value = '';
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
const accountId = pay.value.accountId;
|
||||||
|
const keepId = pay.value.id;
|
||||||
try {
|
try {
|
||||||
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
|
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
|
||||||
if (amount == null || amount <= 0) {
|
if (amount == null || amount <= 0) {
|
||||||
error.value = 'مبلغ پرداخت معتبر نیست.';
|
error.value = 'مبلغ پرداخت معتبر نیست.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
|
await api.post(`/treasury/payroll/${keepId}/pay`, {
|
||||||
amount,
|
amount,
|
||||||
accountId: pay.value.accountId,
|
accountId,
|
||||||
clientKey: crypto.randomUUID(),
|
clientKey: crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
|
ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همینجا بزنید.';
|
||||||
pay.value = { id: '', amount: '', accountId: undefined };
|
|
||||||
await load();
|
await load();
|
||||||
|
const still = rows.value.find(
|
||||||
|
(row) => row.id === keepId && row.status !== 'settled' && row.status !== 'void',
|
||||||
|
);
|
||||||
|
pay.value = still
|
||||||
|
? { id: still.id, amount: still.remaining, accountId }
|
||||||
|
: { id: '', amount: '', accountId };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiMessage(e);
|
error.value = apiMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,14 +249,6 @@ async function payRow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPay(row: PayrollRow) {
|
|
||||||
pay.value = {
|
|
||||||
id: row.id,
|
|
||||||
amount: row.remaining,
|
|
||||||
accountId: accounts.value[0]?.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load().catch(() => {
|
void load().catch(() => {
|
||||||
error.value = 'بارگذاری حقوق ناموفق بود.';
|
error.value = 'بارگذاری حقوق ناموفق بود.';
|
||||||
@@ -148,143 +258,216 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-flex vertical :gap="16">
|
<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 title="تعیین حقوق" :bordered="false">
|
||||||
<a-divider orientation="right">ثبت دوره</a-divider>
|
<a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
|
||||||
<a-form
|
<a-row :gutter="[16, 0]">
|
||||||
layout="vertical"
|
<a-col :xs="24" :md="12">
|
||||||
:model="form"
|
<a-form-item
|
||||||
@finish="createRow"
|
label="پرسنل"
|
||||||
@finishFailed="onFinishFailed"
|
name="userId"
|
||||||
>
|
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||||
<a-row :gutter="[16, 0]">
|
>
|
||||||
<a-col :xs="24" :md="12">
|
<a-select
|
||||||
<a-form-item
|
v-model:value="form.userId"
|
||||||
label="پرسنل"
|
show-search
|
||||||
name="userId"
|
option-filter-prop="label"
|
||||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
allow-clear
|
||||||
>
|
style="width: 100%"
|
||||||
<a-select show-search option-filter-prop="label"
|
placeholder="انتخاب"
|
||||||
v-model:value="form.userId"
|
:options="userOptions"
|
||||||
allow-clear
|
/>
|
||||||
style="width: 100%"
|
</a-form-item>
|
||||||
placeholder="انتخاب"
|
</a-col>
|
||||||
>
|
<a-col :xs="24" :md="12">
|
||||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName">
|
<a-form-item
|
||||||
{{ user.fullName }}
|
label="دوره"
|
||||||
</a-select-option>
|
name="period"
|
||||||
</a-select>
|
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||||
</a-form-item>
|
>
|
||||||
</a-col>
|
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||||
<a-col :xs="24" :md="8">
|
</a-form-item>
|
||||||
<a-form-item
|
</a-col>
|
||||||
label="دوره"
|
<a-col :xs="24" :md="8">
|
||||||
name="period"
|
<a-form-item
|
||||||
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
label="پایه"
|
||||||
>
|
name="baseAmount"
|
||||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
|
||||||
</a-form-item>
|
>
|
||||||
</a-col>
|
<MoneyInput v-model="form.baseAmount" :decimals="0" />
|
||||||
<a-col :xs="24" :md="8">
|
</a-form-item>
|
||||||
<a-form-item
|
</a-col>
|
||||||
label="پایه"
|
<a-col :xs="24" :md="8">
|
||||||
name="baseAmount"
|
<a-form-item label="مزایا" name="benefits">
|
||||||
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
|
<MoneyInput v-model="form.benefits" :decimals="0" />
|
||||||
>
|
</a-form-item>
|
||||||
<MoneyInput v-model="form.baseAmount" :decimals="0" />
|
</a-col>
|
||||||
</a-form-item>
|
<a-col :xs="24" :md="8">
|
||||||
</a-col>
|
<a-form-item label="پاداش" name="bonus">
|
||||||
<a-col :xs="24" :md="8">
|
<MoneyInput v-model="form.bonus" :decimals="0" />
|
||||||
<a-form-item label="مزایا" name="benefits">
|
</a-form-item>
|
||||||
<MoneyInput v-model="form.benefits" :decimals="0" />
|
</a-col>
|
||||||
</a-form-item>
|
<a-col :xs="24" :md="8">
|
||||||
</a-col>
|
<a-form-item label="کسورات" name="deductions">
|
||||||
<a-col :xs="24" :md="8">
|
<MoneyInput v-model="form.deductions" :decimals="0" />
|
||||||
<a-form-item label="پاداش" name="bonus">
|
</a-form-item>
|
||||||
<MoneyInput v-model="form.bonus" :decimals="0" />
|
</a-col>
|
||||||
</a-form-item>
|
<a-col :xs="24" :md="16">
|
||||||
</a-col>
|
<a-form-item label="قابل پرداخت">
|
||||||
<a-col :xs="24" :md="8">
|
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
|
||||||
<a-form-item label="کسورات" name="deductions">
|
</a-form-item>
|
||||||
<MoneyInput v-model="form.deductions" :decimals="0" />
|
</a-col>
|
||||||
</a-form-item>
|
</a-row>
|
||||||
</a-col>
|
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
|
||||||
</a-row>
|
</a-form>
|
||||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
|
</a-card>
|
||||||
</a-form>
|
|
||||||
|
|
||||||
<template v-if="pay.id">
|
<a-card title="پرداخت" :bordered="false">
|
||||||
<a-divider orientation="right">پرداخت حقوق</a-divider>
|
<a-flex vertical :gap="16">
|
||||||
<a-form
|
<a-typography-text type="secondary">
|
||||||
layout="vertical"
|
میتوانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
|
||||||
:model="pay"
|
</a-typography-text>
|
||||||
@finish="payRow"
|
<a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." />
|
||||||
@finishFailed="onFinishFailed"
|
<a-form
|
||||||
>
|
v-else
|
||||||
<a-row :gutter="[16, 0]">
|
layout="vertical"
|
||||||
<a-col :xs="24" :md="8">
|
:model="pay"
|
||||||
<a-form-item
|
@finish="payRow"
|
||||||
label="مبلغ"
|
@finishFailed="onFinishFailed"
|
||||||
name="amount"
|
>
|
||||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
<a-row :gutter="[16, 0]">
|
||||||
>
|
<a-col :xs="24" :md="12">
|
||||||
<MoneyInput v-model="pay.amount" :decimals="0" />
|
<a-form-item
|
||||||
</a-form-item>
|
label="دورهٔ حقوق"
|
||||||
</a-col>
|
name="id"
|
||||||
<a-col :xs="24" :md="8">
|
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||||
<a-form-item
|
>
|
||||||
label="حساب"
|
<a-select
|
||||||
name="accountId"
|
:value="pay.id || undefined"
|
||||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
show-search
|
||||||
>
|
option-filter-prop="label"
|
||||||
<a-select show-search option-filter-prop="label"
|
style="width: 100%"
|
||||||
v-model:value="pay.accountId"
|
placeholder="انتخاب پرسنل و دوره"
|
||||||
allow-clear
|
:options="payOptions"
|
||||||
style="width: 100%"
|
@update:value="selectPay(String($event ?? ''))"
|
||||||
placeholder="انتخاب حساب"
|
/>
|
||||||
>
|
</a-form-item>
|
||||||
<a-select-option
|
</a-col>
|
||||||
v-for="account in accounts"
|
<a-col :xs="24" :md="6">
|
||||||
:key="account.id"
|
<a-form-item
|
||||||
:value="account.id"
|
label="مبلغ این مرحله"
|
||||||
:label="account.name"
|
name="amount"
|
||||||
>
|
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||||
{{ account.name }}
|
>
|
||||||
</a-select-option>
|
<MoneyInput v-model="pay.amount" :decimals="0" />
|
||||||
</a-select>
|
</a-form-item>
|
||||||
</a-form-item>
|
</a-col>
|
||||||
</a-col>
|
<a-col :xs="24" :md="6">
|
||||||
</a-row>
|
<a-form-item
|
||||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
|
label="حساب"
|
||||||
</a-form>
|
name="accountId"
|
||||||
</template>
|
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
|
v-model:value="pay.accountId"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
allow-clear
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="انتخاب حساب"
|
||||||
|
:options="accounts.map((account) => ({ value: account.id, label: account.name }))"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
<a-flex v-if="selectedPay" :gap="16" wrap="wrap">
|
||||||
|
<a-typography-text>قابل پرداخت {{ toman(selectedPay.payable) }}</a-typography-text>
|
||||||
|
<a-typography-text>پرداختشده {{ toman(selectedPay.paidAmount) }}</a-typography-text>
|
||||||
|
<a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text>
|
||||||
|
</a-flex>
|
||||||
|
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
|
||||||
|
ثبت پرداخت
|
||||||
|
</a-button>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
<a-divider orientation="right">فهرست</a-divider>
|
<a-table
|
||||||
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
|
v-if="selectedPay?.payments.length"
|
||||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
:columns="paymentColumns"
|
||||||
<template #renderItem="{ item }">
|
:data-source="selectedPay.payments"
|
||||||
<a-list-item>
|
:pagination="false"
|
||||||
<template #actions>
|
row-key="id"
|
||||||
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
|
/>
|
||||||
<a-button
|
</a-flex>
|
||||||
v-if="item.status !== 'settled' && item.status !== 'void'"
|
</a-card>
|
||||||
type="link"
|
|
||||||
@click="startPay(item)"
|
<a-card title="گزارش حقوق" :bordered="false">
|
||||||
>
|
<a-form layout="vertical">
|
||||||
پرداخت
|
<a-row :gutter="[16, 0]">
|
||||||
</a-button>
|
<a-col :xs="24" :md="8">
|
||||||
</template>
|
<a-form-item label="پرسنل">
|
||||||
<a-list-item-meta>
|
<a-select
|
||||||
<template #title>{{ item.fullName }}</template>
|
v-model:value="filterUserId"
|
||||||
<template #description>
|
allow-clear
|
||||||
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
|
show-search
|
||||||
</template>
|
option-filter-prop="label"
|
||||||
</a-list-item-meta>
|
style="width: 100%"
|
||||||
</a-list-item>
|
placeholder="همه"
|
||||||
|
:options="userOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :md="8">
|
||||||
|
<a-form-item label="دوره">
|
||||||
|
<a-input v-model:value="filterPeriod" placeholder="مثلاً ۱۴۰۵-۰۷" allow-clear />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :md="8">
|
||||||
|
<a-form-item label="وضعیت">
|
||||||
|
<a-select v-model:value="filterStatus" style="width: 100%" :options="statusOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :md="8">
|
||||||
|
<a-form-item label="حداقل قابل پرداخت">
|
||||||
|
<MoneyInput v-model="filterMin" :decimals="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :md="8">
|
||||||
|
<a-form-item label="حداکثر قابل پرداخت">
|
||||||
|
<MoneyInput v-model="filterMax" :decimals="0" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
<a-table
|
||||||
|
:columns="reportColumns"
|
||||||
|
:data-source="filtered"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 'max-content' }"
|
||||||
|
:locale="{ emptyText: 'رکوردی با این فیلتر نیست.' }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
{{ statusLabel[record.status] ?? record.status }}
|
||||||
</template>
|
</template>
|
||||||
</a-list>
|
<template v-else-if="column.key === 'action'">
|
||||||
</a-card>
|
<a-button
|
||||||
|
v-if="record.status === 'open' || record.status === 'partial'"
|
||||||
|
type="link"
|
||||||
|
@click="selectPay(record.id)"
|
||||||
|
>
|
||||||
|
پرداخت
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
|
||||||
|
{{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ column.dataIndex ? record[column.dataIndex as 'fullName' | 'period'] : '' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const ATabPane = ATabs.TabPane;
|
|||||||
|
|
||||||
type Period = {
|
type Period = {
|
||||||
bookedIncome: string;
|
bookedIncome: string;
|
||||||
|
invoiceSales: string;
|
||||||
|
invoiceCredits: string;
|
||||||
bookedExpense: string;
|
bookedExpense: string;
|
||||||
operatingProfit: string;
|
operatingProfit: string;
|
||||||
cashIn: string;
|
cashIn: string;
|
||||||
@@ -61,6 +63,8 @@ const kindLabel: Record<string, string> = {
|
|||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
['درآمد ثبتشده', 'bookedIncome'],
|
['درآمد ثبتشده', 'bookedIncome'],
|
||||||
|
['فروش فاکتور', 'invoiceSales'],
|
||||||
|
['فاکتور برگشتی', 'invoiceCredits'],
|
||||||
['هزینه ثبتشده', 'bookedExpense'],
|
['هزینه ثبتشده', 'bookedExpense'],
|
||||||
['سود عملیاتی', 'operatingProfit'],
|
['سود عملیاتی', 'operatingProfit'],
|
||||||
['ورود وجه', 'cashIn'],
|
['ورود وجه', 'cashIn'],
|
||||||
@@ -79,8 +83,8 @@ const metricRows = computed<MetricRow[]>(() => {
|
|||||||
return lines.map(([label, key]) => ({
|
return lines.map(([label, key]) => ({
|
||||||
key,
|
key,
|
||||||
label,
|
label,
|
||||||
current: toman(current.value![key]),
|
current: toman(current.value![key] || '0'),
|
||||||
previous: toman(previous.value![key]),
|
previous: toman(previous.value![key] || '0'),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,6 +248,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<a-tab-pane key="debtors" tab="بدهکاران">
|
<a-tab-pane key="debtors" tab="بدهکاران">
|
||||||
<a-flex vertical :gap="16">
|
<a-flex vertical :gap="16">
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمیسازد.
|
||||||
|
</a-typography-text>
|
||||||
<a-flex justify="space-between" align="center">
|
<a-flex justify="space-between" align="center">
|
||||||
<a-typography-text type="secondary">
|
<a-typography-text type="secondary">
|
||||||
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
||||||
@@ -259,6 +266,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<a-tab-pane key="creditors" tab="بستانکاران">
|
<a-tab-pane key="creditors" tab="بستانکاران">
|
||||||
<a-flex vertical :gap="16">
|
<a-flex vertical :gap="16">
|
||||||
|
<a-typography-text type="secondary">
|
||||||
|
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمیسازد.
|
||||||
|
</a-typography-text>
|
||||||
<a-flex justify="space-between" align="center">
|
<a-flex justify="space-between" align="center">
|
||||||
<a-typography-text type="secondary">
|
<a-typography-text type="secondary">
|
||||||
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5511,13 +5511,21 @@ export type VoipCallStatus =
|
|||||||
| "completed"
|
| "completed"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
|
export type VoipProvider = "generic" | "daftareshoma";
|
||||||
|
|
||||||
export interface VoipConnectionDto {
|
export interface VoipConnectionDto {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
provider: VoipProvider;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
webhookSecretSet: boolean;
|
webhookSecretSet: boolean;
|
||||||
|
apiTokenSet: boolean;
|
||||||
/** قالب URL برای شروع تماس؛ {number} جایگزین میشود */
|
/** قالب URL برای شروع تماس؛ {number} جایگزین میشود */
|
||||||
clickToCallUrl: string | null;
|
clickToCallUrl: string | null;
|
||||||
|
/** شماره مبدأ تماس خروجی (دفتر شما) */
|
||||||
|
fromNumber: string | null;
|
||||||
|
/** خط/داخلی سازمان (دفتر شما) */
|
||||||
|
callerExtension: string | null;
|
||||||
lastEventAt: string | null;
|
lastEventAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
callCount: number;
|
callCount: number;
|
||||||
@@ -5527,8 +5535,13 @@ export interface VoipConnectionDto {
|
|||||||
|
|
||||||
export interface CreateVoipConnectionRequest {
|
export interface CreateVoipConnectionRequest {
|
||||||
label: string;
|
label: string;
|
||||||
|
provider?: VoipProvider;
|
||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
clickToCallUrl?: string | null;
|
clickToCallUrl?: string | null;
|
||||||
|
/** توکن Bearer API دفتر شما */
|
||||||
|
apiToken?: string;
|
||||||
|
fromNumber?: string | null;
|
||||||
|
callerExtension?: string | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5536,6 +5549,9 @@ export interface UpdateVoipConnectionRequest {
|
|||||||
label?: string;
|
label?: string;
|
||||||
webhookSecret?: string | null;
|
webhookSecret?: string | null;
|
||||||
clickToCallUrl?: string | null;
|
clickToCallUrl?: string | null;
|
||||||
|
apiToken?: string | null;
|
||||||
|
fromNumber?: string | null;
|
||||||
|
callerExtension?: string | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5553,6 +5569,8 @@ export interface VoipCallLogDto {
|
|||||||
status: VoipCallStatus | string;
|
status: VoipCallStatus | string;
|
||||||
fromNumber: string | null;
|
fromNumber: string | null;
|
||||||
toNumber: string | null;
|
toNumber: string | null;
|
||||||
|
/** خط/داخلی سازمانی که تماس روی آن بوده */
|
||||||
|
lineExtension: string | null;
|
||||||
personId: string | null;
|
personId: string | null;
|
||||||
personName: string | null;
|
personName: string | null;
|
||||||
activityId: string | null;
|
activityId: string | null;
|
||||||
@@ -5579,6 +5597,8 @@ export interface VoipWebhookEvent {
|
|||||||
status: VoipCallStatus | string;
|
status: VoipCallStatus | string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** خط/داخلی سازمان */
|
||||||
|
lineExtension?: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user