7 Commits
Author SHA1 Message Date
MahdiandCursor 97f00d7e6d fix(products): open one row menu so edit and other actions run
Desktop and mobile dropdowns shared an id, so both opened and the extra menu closed the click before the action.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
24 changed files with 1900 additions and 247 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+129
View File
@@ -0,0 +1,129 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
+17 -2
View File
@@ -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 },
+159 -4
View File
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, ''); return (value ?? '').replace(/\D/g, '');
@@ -57,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService, private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, organizationId,
...(connectionId ? { connectionId } : {}), ...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
? { direction: opts.direction }
: {}),
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
...(opts?.lineExtension?.trim()
? { lineExtension: opts.lineExtension.trim() }
: {}),
}, },
orderBy: { syncedAt: 'desc' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -91,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -127,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || null; const toNumber = toNormalized || toDigits || null;
const lineRaw = String(event.lineExtension ?? '').trim();
const lineExtension =
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
const matchNumber = direction === 'inbound' ? fromNumber : toNumber; const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -193,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -213,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -240,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber); await this.notifyIncoming(conn.organizationId, personId, fromNumber);
} }
if (
conn.provider === 'daftareshoma' &&
['completed', 'answered', 'missed'].includes(status)
) {
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
this.logger.warn(
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
);
});
}
const link = await this.prisma.voipCallLog.findUnique({ const link = await this.prisma.voipCallLog.findUnique({
where: { where: {
connectionId_externalCallId: { connectionId_externalCallId: {
@@ -261,10 +296,67 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: string, personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> { ): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number); const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست'); if (!phone) throw new BadRequestException('شماره معتبر نیست');
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
provider: 'daftareshoma',
},
orderBy: { createdAt: 'asc' },
});
if (daftareshomaConn) {
const token = await this.connections.decryptApiToken(daftareshomaConn);
const fromNumber = daftareshomaConn.fromNumber?.trim();
const callerExtension = daftareshomaConn.callerExtension?.trim();
if (!token || !fromNumber || !callerExtension) {
throw new BadRequestException(
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
);
}
const toE164 = this.toPlusDigits(phone);
const fromE164 = this.toPlusDigits(fromNumber);
const extE164 = this.toPlusDigits(callerExtension);
try {
await this.daftareshoma.outgoingCall(token, {
fromNumber: fromE164,
toNumber: toE164,
callerExtension: extE164,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطا';
await this.prisma.voipConnection.update({
where: { id: daftareshomaConn.id },
data: { lastError: msg.slice(0, 400) },
});
throw new BadRequestException(msg);
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: daftareshomaConn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: fromE164,
toNumber: toE164,
personId: personId ?? null,
startedAt: new Date(),
},
});
return {
ok: true,
mode: 'daftareshoma',
dialUrl: `daftareshoma:${toE164}`,
};
}
const conn = await this.prisma.voipConnection.findFirst({ const conn = await this.prisma.voipConnection.findFirst({
where: { where: {
organizationId, organizationId,
@@ -318,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
} }
private toPlusDigits(raw: string): string {
const d = digitsOnly(raw);
if (!d) return raw.trim();
if (raw.trim().startsWith('+')) return `+${d}`;
if (d.startsWith('98')) return `+${d}`;
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
return `+${d}`;
}
private async enrichDaftaresomaRecording(
connectionId: string,
callId: string,
): Promise<void> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
});
if (!conn) return;
const log = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: { connectionId, externalCallId: callId },
},
});
if (!log) return;
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
if (
parseDaftaresomaRecordingRef(log.recordingUrl) &&
!['done', 'pending', 'processing'].includes(log.summaryStatus)
) {
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
summaryStatus: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
return;
}
const token = await this.connections.decryptApiToken(conn);
if (!token) return;
const row = await this.daftareshoma.findCallById(token, callId);
const recordingId = row?.recordingId?.trim();
if (!recordingId) return;
const ref = daftareshomaRecordingRef(recordingId);
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
recordingUrl: ref,
summaryStatus: ['done', 'pending', 'processing'].includes(
log.summaryStatus,
)
? log.summaryStatus
: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
private shouldCreateActivity(status: string): boolean { private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status); return ['answered', 'completed', 'missed', 'failed'].includes(status);
} }
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest, CreateVoipConnectionRequest,
UpdateVoipConnectionRequest, UpdateVoipConnectionRequest,
VoipConnectionDto, VoipConnectionDto,
VoipProvider,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable() @Injectable()
export class VoipConnectionsService { export class VoipConnectionsService {
constructor( constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> { ): Promise<VoipConnectionDto> {
const label = dto.label?.trim(); const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است'); if (!label) throw new BadRequestException('نام اتصال الزامی است');
const provider = asProvider(dto.provider);
if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
}
const secret = dto.webhookSecret?.trim(); const secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null; const secretEnc = secret ? this.crypto.encrypt(secret) : null;
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); const token = dto.apiToken?.trim();
const tokenEnc = token ? this.crypto.encrypt(token) : null;
const clickToCallUrl =
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
const row = await this.prisma.voipConnection.create({ const row = await this.prisma.voipConnection.create({
data: { data: {
organizationId, organizationId,
label, label,
provider,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
clickToCallUrl, clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv, webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag, webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
}, },
}); });
return this.toDto(row, 0); return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) { if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
} }
if (dto.fromNumber !== undefined) {
data.fromNumber = dto.fromNumber?.trim() || null;
}
if (dto.callerExtension !== undefined) {
data.callerExtension = dto.callerExtension?.trim() || null;
}
if (dto.webhookSecret !== undefined) { if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim(); const wh = dto.webhookSecret?.trim();
if (!wh) { if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag; data.webhookSecretTag = enc.tag;
} }
} }
if (dto.apiToken !== undefined) {
const tok = dto.apiToken?.trim();
if (!tok) {
data.apiTokenCiphertext = null;
data.apiTokenIv = null;
data.apiTokenTag = null;
} else {
const enc = this.crypto.encrypt(tok);
data.apiTokenCiphertext = enc.ciphertext;
data.apiTokenIv = enc.iv;
data.apiTokenTag = enc.tag;
}
}
const row = await this.prisma.voipConnection.update({ const row = await this.prisma.voipConnection.update({
where: { id: existing.id }, where: { id: existing.id },
data, data,
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
}); });
} }
async decryptApiToken(row: {
apiTokenCiphertext: string | null;
apiTokenIv: string | null;
apiTokenTag: string | null;
}): Promise<string | null> {
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.apiTokenCiphertext,
iv: row.apiTokenIv,
tag: row.apiTokenTag,
});
}
verifySignature( verifySignature(
rawBody: string | Buffer, rawBody: string | Buffer,
signature: string | undefined, signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: { row: {
id: string; id: string;
label: string; label: string;
provider?: string | null;
enabled: boolean; enabled: boolean;
webhookSecretCiphertext: string | null; webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null; clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null; lastEventAt: Date | null;
lastError: string | null; lastError: string | null;
createdAt: Date; createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return { return {
id: row.id, id: row.id,
label: row.label, label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled, enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext), webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl, clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError, lastError: row.lastError,
callCount, callCount,
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
export type DaftaresomaOutgoingCallInput = {
fromNumber: string;
toNumber: string;
callerExtension: string;
};
export type DaftaresomaCallSearchRow = {
id?: string;
fromNumber?: string;
toNumber?: string;
recordingId?: string | null;
direction?: string;
reportDispositionType?: string;
startTime?: string;
endTime?: string;
duration?: string;
extension?: string;
};
@Injectable()
export class DaftaresomaClient {
private readonly logger = new Logger(DaftaresomaClient.name);
async outgoingCall(
apiToken: string,
input: DaftaresomaOutgoingCallInput,
): Promise<void> {
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from_number: input.fromNumber,
to_number: input.toNumber,
caller_extension: input.callerExtension,
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
);
throw new Error(
text.trim()
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
);
}
}
async findCallById(
apiToken: string,
callId: string,
): Promise<DaftaresomaCallSearchRow | null> {
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
);
return null;
}
const json = (await res.json()) as {
data?: DaftaresomaCallSearchRow[];
};
const row = Array.isArray(json.data) ? json.data[0] : null;
return row ?? null;
}
async downloadRecording(
apiToken: string,
recordingId: string,
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
const url = new URL(`${BASE_URL}/GetCallRecording`);
url.searchParams.set('recordingId', recordingId);
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: '*/*',
'User-Agent': 'MontactCRM-VoIP/1.0',
},
signal: AbortSignal.timeout(60000),
redirect: 'follow',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
);
}
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
const buffer = await res.arrayBuffer();
if (buffer.byteLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
if (!buffer.byteLength) {
throw new Error('فایل ضبط خالی است');
}
const contentType =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
const filename = `daftareshoma-${recordingId}.wav`;
return { buffer, contentType, filename };
}
}
@@ -0,0 +1,168 @@
import type { VoipWebhookEvent } from '@mento/shared';
/** پیشوند ذخیره‌شده در recordingUrl برای واکشی بعدی با توکن API */
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
export function daftareshomaRecordingRef(recordingId: string): string {
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
}
export function parseDaftaresomaRecordingRef(
value: string | null | undefined,
): string | null {
const raw = String(value ?? '').trim();
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
return id || null;
}
type Loose = Record<string, unknown>;
function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
function pick(obj: Loose, ...keys: string[]): string {
for (const k of keys) {
const v = str(obj[k]);
if (v) return v;
}
return '';
}
function parseDurationSec(raw: unknown): number | undefined {
if (raw == null || raw === '') return undefined;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.max(0, Math.round(raw));
}
const s = String(raw).trim();
if (!s) return undefined;
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
const parts = s.split(':').map((p) => Number(p));
if (parts.some((n) => !Number.isFinite(n))) return undefined;
if (parts.length === 3) {
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
}
if (parts.length === 2) {
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
}
return undefined;
}
/**
* رویداد وب‌هوک دفتر شما را به VoipWebhookEvent تبدیل می‌کند.
* اگر payload از قبل فرمت Montact باشد، همان را برمی‌گرداند.
*/
export function mapDaftaresomaWebhook(
body: unknown,
): VoipWebhookEvent | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Loose;
const legacyCallId = pick(obj, 'callId');
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
return body as VoipWebhookEvent;
}
const eventType = pick(obj, 'EventType', 'eventType', 'event');
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
if (!callId) return null;
const caller = pick(
obj,
'CallerNumber',
'callerNumber',
'from',
'From',
'fromNumber',
);
/** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj,
'TransferredToNumber',
'transferredToNumber',
);
const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt =
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
undefined;
const durationSec = parseDurationSec(
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
);
const lower = eventType.toLowerCase();
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
const isStarted =
lower.includes('started') ||
lower.includes('ringing') ||
lower.includes('transferstarted');
const isEnded =
lower.includes('ended') ||
lower.includes('completed') ||
lower.includes('hangup');
let status: string = 'completed';
if (isStarted && !isEnded) {
status = 'ringing';
} else if (isEnded) {
const result = obj.Result ?? obj.result;
if (result === false || result === 'false' || result === 0) {
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
} else if (result === true || result === 'true' || result === 1) {
status = 'completed';
} else {
status = 'completed';
}
}
const recordingId = pick(
obj,
'RecordingId',
'recordingId',
'RecordId',
'recordId',
);
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
// outbound: from=همکار، to=مشتری (TransferredToNumber)
let from = caller || undefined;
let to: string | undefined;
if (isOutbound) {
to = transferred || undefined;
} else {
to = transferred || lineExtension || undefined;
}
const event: VoipWebhookEvent = {
callId,
direction: isOutbound ? 'outbound' : 'inbound',
status,
from,
to,
lineExtension,
startedAt,
endedAt,
durationSec,
caller: from,
callee: to,
};
if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId);
}
return event;
}
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const obj = body as Loose;
return Boolean(
pick(obj, 'EventType', 'eventType') ||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
);
}
+35
View File
@@ -1,5 +1,6 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80) @MaxLength(80)
label!: string; label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
} }
const timeoutMs = Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
);
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
return this.transcribeAudio(audio, timeoutMs);
}
async transcribeAudio(
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
timeoutMs?: number,
): Promise<string> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
}
const baseUrl = this.config.get<string>('STT_URL')?.trim(); const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) { if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
} }
const timeoutMs = Number( const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000, this.config.get<string>('STT_TIMEOUT_MS') || 180000,
); );
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || ''; const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData(); const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], { const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType, type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token; if (token) headers['X-STT-Token'] = token;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), waitMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto, UpdateVoipConnectionDto,
VoipClickToCallDto, VoipClickToCallDto,
} from './dto'; } from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip') @Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string, @Query('connectionId') connectionId?: string,
@Query('direction') direction?: string,
@Query('status') status?: string,
@Query('lineExtension') lineExtension?: string,
) { ) {
return this.calls.listRecent(user.organizationId, connectionId); return this.calls.listRecent(user.organizationId, {
connectionId,
direction,
status,
lineExtension,
});
} }
@Post('click-to-call') @Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
} }
if (!body?.callId) { let event: VoipWebhookEvent | null = null;
if (
row.provider === 'daftareshoma' ||
isDaftaresomaWebhookPayload(body)
) {
event = mapDaftaresomaWebhook(body);
} else if (body?.callId) {
event = body;
}
if (!event?.callId) {
return { ok: false, error: 'callId required' }; return { ok: false, error: 'callId required' };
} }
const result = await this.calls.ingestWebhook(connectionId, body); const result = await this.calls.ingestWebhook(connectionId, event);
return { ok: true, ...result }; return { ok: true, ...result };
} }
} }
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service'; import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service'; import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller'; import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -258,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -291,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
+69
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,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> {
+495 -136
View File
@@ -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>
+29 -13
View File
@@ -411,25 +411,25 @@ onMounted(load);
</td> </td>
<td class="ops" @click.stop> <td class="ops" @click.stop>
<UiDropdownMenu <UiDropdownMenu
:open="menuId === p.id" :open="menuId === `d:${p.id}`"
align="end" align="end"
@update:open="(v) => (menuId = v ? p.id : null)" @update:open="(v) => (menuId = v ? `d:${p.id}` : null)"
> >
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<button <button
type="button" type="button"
class="icon-btn" class="more"
aria-label="عملیات" aria-label="عملیات"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="dots" :size="16" /> <UiIcon name="dots-v" :size="16" />
</button> </button>
</template> </template>
<button type="button" @click="goEdit(p.id)">ویرایش</button> <button type="button" @click.stop="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)"> <button type="button" @click.stop="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} {{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> <button type="button" class="danger" @click.stop="removeProduct(p)">حذف</button>
</UiDropdownMenu> </UiDropdownMenu>
</td> </td>
</tr> </tr>
@@ -460,25 +460,25 @@ onMounted(load);
</div> </div>
<div class="mob-side"> <div class="mob-side">
<UiDropdownMenu <UiDropdownMenu
:open="menuId === p.id" :open="menuId === `m:${p.id}`"
align="end" align="end"
@update:open="(v) => (menuId = v ? p.id : null)" @update:open="(v) => (menuId = v ? `m:${p.id}` : null)"
> >
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<button <button
type="button" type="button"
class="icon-btn" class="more"
aria-label="عملیات" aria-label="عملیات"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="dots-v" :size="16" /> <UiIcon name="dots-v" :size="16" />
</button> </button>
</template> </template>
<button type="button" @click="goEdit(p.id)">ویرایش</button> <button type="button" @click.stop="goEdit(p.id)">ویرایش</button>
<button type="button" @click="toggleActive(p)"> <button type="button" @click.stop="toggleActive(p)">
{{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }} {{ p.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
</button> </button>
<button type="button" class="danger" @click="removeProduct(p)">حذف</button> <button type="button" class="danger" @click.stop="removeProduct(p)">حذف</button>
</UiDropdownMenu> </UiDropdownMenu>
<UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot> <UiBadge :variant="p.isActive ? 'success' : 'neutral'" size="sm" dot>
{{ p.isActive ? 'فعال' : 'غیرفعال' }} {{ p.isActive ? 'فعال' : 'غیرفعال' }}
@@ -680,6 +680,22 @@ tr:last-child td {
.ops { .ops {
width: 64px; width: 64px;
} }
.more {
width: 32px;
height: 32px;
border: 0;
background: none;
color: #94a3b8;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.more:hover {
background: var(--accent-soft);
color: var(--accent);
}
.mob-list { .mob-list {
display: none; display: none;
gap: 0.65rem; gap: 0.65rem;
+395 -84
View File
@@ -20,12 +20,20 @@ import {
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
type TabKey = 'generic' | 'daftareshoma';
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const items = ref<VoipConnectionDto[]>([]); const items = ref<VoipConnectionDto[]>([]);
const calls = ref<VoipCallLogDto[]>([]); const calls = ref<VoipCallLogDto[]>([]);
const tab = ref<TabKey>('daftareshoma');
const callFilters = reactive({
direction: '',
status: '',
lineExtension: '',
});
const form = reactive({ const form = reactive({
label: 'سانترال اصلی', label: 'سانترال اصلی',
@@ -34,12 +42,44 @@ const form = reactive({
enabled: true, enabled: true,
}); });
const webhookUrl = computed(() => { const daftaForm = reactive({
if (!items.value[0]) return ''; label: 'دفتر شما',
const origin = typeof window !== 'undefined' ? window.location.origin : ''; apiToken: '',
return `${origin}/api/public/integrations/voip/${items.value[0].id}/webhook`; fromNumber: '',
callerExtension: '',
enabled: true,
}); });
const genericItems = computed(() =>
items.value.filter((c) => c.provider !== 'daftareshoma'),
);
const daftaItems = computed(() =>
items.value.filter((c) => c.provider === 'daftareshoma'),
);
const activeDafta = computed(() => daftaItems.value[0] ?? null);
const knownLines = ref<string[]>([]);
const lineOptions = computed(() => {
const set = new Set(knownLines.value);
for (const c of calls.value) {
if (c.lineExtension) set.add(c.lineExtension);
}
if (callFilters.lineExtension) set.add(callFilters.lineExtension);
return [...set].sort();
});
const webhookUrlFor = (id: string | undefined) => {
if (!id) return '';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/api/public/integrations/voip/${id}/webhook`;
};
const genericWebhookUrl = computed(() =>
webhookUrlFor(genericItems.value[0]?.id),
);
const daftaWebhookUrl = computed(() => webhookUrlFor(activeDafta.value?.id));
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
ringing: 'زنگ', ringing: 'زنگ',
answered: 'پاسخ', answered: 'پاسخ',
@@ -63,16 +103,32 @@ function noteLink(c: VoipCallLogDto): string | null {
return '/notes'; return '/notes';
} }
async function loadCalls() {
const params: Record<string, string> = {};
if (callFilters.direction) params.direction = callFilters.direction;
if (callFilters.status) params.status = callFilters.status;
if (callFilters.lineExtension) params.lineExtension = callFilters.lineExtension;
const callRes = await api.get<VoipCallLogDto[]>('/integrations/voip/calls', {
params,
});
calls.value = callRes.data;
const merged = new Set(knownLines.value);
for (const c of callRes.data) {
if (c.lineExtension) merged.add(c.lineExtension);
}
knownLines.value = [...merged].sort();
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [connRes, callRes] = await Promise.all([ const [connRes] = await Promise.all([
api.get<VoipConnectionDto[]>('/integrations/voip/connections'), api.get<VoipConnectionDto[]>('/integrations/voip/connections'),
api.get<VoipCallLogDto[]>('/integrations/voip/calls'), loadCalls(),
]); ]);
items.value = connRes.data; items.value = connRes.data;
calls.value = callRes.data; fillDaftaFromActive();
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) { if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.'; error.value = 'ماژول تلفن سازمانی برای این محیط فعال نیست.';
@@ -84,16 +140,30 @@ async function load() {
} }
} }
async function create() { async function applyCallFilters() {
loading.value = true;
error.value = '';
try {
await loadCalls();
} catch {
error.value = 'فیلتر تماس‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createGeneric() {
if (!form.label.trim()) { if (!form.label.trim()) {
error.value = 'نام اتصال را وارد کنید.'; error.value = 'نام اتصال را وارد کنید.';
return; return;
} }
saving.value = true; saving.value = true;
error.value = ''; error.value = '';
ok.value = '';
try { try {
const payload: CreateVoipConnectionRequest = { const payload: CreateVoipConnectionRequest = {
label: form.label.trim(), label: form.label.trim(),
provider: 'generic',
webhookSecret: form.webhookSecret.trim() || undefined, webhookSecret: form.webhookSecret.trim() || undefined,
clickToCallUrl: form.clickToCallUrl.trim() || null, clickToCallUrl: form.clickToCallUrl.trim() || null,
enabled: form.enabled, enabled: form.enabled,
@@ -111,6 +181,65 @@ async function create() {
} }
} }
async function saveDafta() {
if (!daftaForm.label.trim()) {
error.value = 'نام اتصال را وارد کنید.';
return;
}
if (!activeDafta.value && !daftaForm.apiToken.trim()) {
error.value = 'توکن API دفتر شما را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (activeDafta.value) {
await api.patch(`/integrations/voip/connections/${activeDafta.value.id}`, {
label: daftaForm.label.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
...(daftaForm.apiToken.trim()
? { apiToken: daftaForm.apiToken.trim() }
: {}),
});
daftaForm.apiToken = '';
ok.value = 'اتصال دفتر شما بروزرسانی شد.';
} else {
const payload: CreateVoipConnectionRequest = {
label: daftaForm.label.trim(),
provider: 'daftareshoma',
apiToken: daftaForm.apiToken.trim(),
fromNumber: daftaForm.fromNumber.trim() || null,
callerExtension: daftaForm.callerExtension.trim() || null,
enabled: daftaForm.enabled,
};
await api.post('/integrations/voip/connections', payload);
daftaForm.apiToken = '';
ok.value =
'اتصال دفتر شما ذخیره شد. آدرس وب‌هوک را در پرتال دفتر شما ثبت کنید.';
}
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
saving.value = false;
}
}
function fillDaftaFromActive() {
const c = activeDafta.value;
if (!c) return;
daftaForm.label = c.label;
daftaForm.fromNumber = c.fromNumber ?? '';
daftaForm.callerExtension = c.callerExtension ?? '';
daftaForm.enabled = c.enabled;
daftaForm.apiToken = '';
}
async function remove(id: string) { async function remove(id: string) {
if (!confirm('این اتصال حذف شود؟')) return; if (!confirm('این اتصال حذف شود؟')) return;
try { try {
@@ -121,18 +250,19 @@ async function remove(id: string) {
} }
} }
async function copyWebhook() { async function copyText(text: string, msg: string) {
if (!webhookUrl.value) return; if (!text) return;
try { try {
await navigator.clipboard.writeText(webhookUrl.value); await navigator.clipboard.writeText(text);
ok.value = 'آدرس وب‌هوک کپی شد.'; ok.value = msg;
} catch { } catch {
error.value = 'کپی ممکن نشد.'; error.value = 'کپی ممکن نشد.';
} }
} }
onMounted(() => { onMounted(async () => {
void load(); await load();
fillDaftaFromActive();
}); });
</script> </script>
@@ -150,81 +280,222 @@ onMounted(() => {
</template> </template>
</UiPageHeader> </UiPageHeader>
<nav class="tabs" aria-label="نوع اتصال تلفن">
<button
type="button"
:class="{ on: tab === 'daftareshoma' }"
@click="tab = 'daftareshoma'"
>
دفتر شما
</button>
<button
type="button"
:class="{ on: tab === 'generic' }"
@click="tab = 'generic'"
>
وب‌هوک عمومی
</button>
</nav>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p> <p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="اتصال جدید"> <template v-if="tab === 'daftareshoma'">
<p class="hint"> <UiCard title="اتصال دفتر شما">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد. <p class="hint">
فیلدهای اصلی: برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
<code>callId</code>، توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<code>direction</code> (inbound/outbound)، <strong>تنظیمات → هوک</strong>
<code>status</code>، با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
<code>from</code>، فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
<code>to</code>. </p>
برای خلاصه خودکار یکی از <div class="form-grid">
<code>recordingUrl</code> <UiField label="نام">
(لینک فایل ضبط) یا <UiInput v-model="daftaForm.label" placeholder="دفتر شما" />
<code>transcript</code> </UiField>
(متن آماده) را بفرستید؛ پس از <UiField
<code>completed</code> :label="activeDafta?.apiTokenSet ? 'توکن API (خالی = بدون تغییر)' : 'توکن API'"
/ >
<code>answered</code> <UiInput
صف خلاصه ساخته می‌شود. v-model="daftaForm.apiToken"
امضا اختیاری با هدر type="password"
<code>X-Voip-Signature</code> placeholder="توکن از پرتال دفتر شما"
(HMAC-SHA256 هگز). autocomplete="off"
</p> />
<div class="form-grid"> </UiField>
<UiField label="نام"> <UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput v-model="form.label" placeholder="سانترال اصلی" /> <UiInput
</UiField> v-model="daftaForm.fromNumber"
<UiField label="رمز وب‌هوک (اختیاری)"> placeholder="موبایل همکار برای click-to-call"
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" /> dir="ltr"
</UiField> />
<UiField label="آدرس Click-to-Call (اختیاری)"> </UiField>
<UiInput <UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
v-model="form.clickToCallUrl" <UiInput
placeholder="https://pbx.example/originate?num={number}" v-model="daftaForm.callerExtension"
/> placeholder="مثلاً 02128425990"
</UiField> dir="ltr"
</div> />
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton> </UiField>
</UiCard> </div>
<UiButton :loading="saving" @click="saveDafta">
{{ activeDafta ? 'بروزرسانی اتصال' : 'ذخیره اتصال دفتر شما' }}
</UiButton>
</UiCard>
<UiCard title="اتصال‌ها"> <UiCard title="اتصال‌های دفتر شما">
<UiEmptyState <UiEmptyState
v-if="!loading && !items.length" v-if="!loading && !daftaItems.length"
title="اتصالی نیست" title="اتصالی نیست"
description="یک اتصال برای دریافت رویداد تماس بسازید." description="توکن و مشخصات خط را ذخیره کنید."
/> />
<div v-else class="list"> <div v-else class="list">
<article v-for="c in items" :key="c.id" class="item"> <article v-for="c in daftaItems" :key="c.id" class="item">
<div> <div>
<strong>{{ c.label }}</strong> <strong>{{ c.label }}</strong>
<p class="muted"> <p class="muted">
تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }} تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }}
· ·
{{ {{
c.lastEventAt c.lastEventAt
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}` ? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
: 'هنوز رویدادی نیامده' : 'هنوز رویدادی نیامده'
}} }}
</p> </p>
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p> <p class="muted">
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p> مبدأ {{ c.fromNumber || '—' }} · خط {{ c.callerExtension || '—' }}
</div> · توکن {{ c.apiTokenSet ? 'ثبت شده' : 'ندارد' }}
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton> </p>
</article> <p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div> </div>
<div v-if="webhookUrl" class="webhook"> <UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
<p class="hint">آدرس وب‌هوک:</p> </article>
<code>{{ webhookUrl }}</code> </div>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton> <div v-if="daftaWebhookUrl" class="webhook">
</div> <p class="hint">
</UiCard> آدرس وب‌هوک برای پرتال دفتر شما (رویدادها را با JSON به این آدرس POST کنید):
</p>
<code>{{ daftaWebhookUrl }}</code>
<UiButton
variant="secondary"
@click="copyText(daftaWebhookUrl, 'آدرس وب‌هوک دفتر شما کپی شد.')"
>
کپی
</UiButton>
</div>
</UiCard>
</template>
<template v-else>
<UiCard title="اتصال جدید">
<p class="hint">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد.
فیلدهای اصلی:
<code>callId</code>،
<code>direction</code> (inbound/outbound)،
<code>status</code>،
<code>from</code>،
<code>to</code>.
برای خلاصه خودکار یکی از
<code>recordingUrl</code>
(لینک فایل ضبط) یا
<code>transcript</code>
(متن آماده) را بفرستید؛ پس از
<code>completed</code>
/
<code>answered</code>
صف خلاصه ساخته می‌شود.
امضا اختیاری با هدر
<code>X-Voip-Signature</code>
(HMAC-SHA256 هگز).
</p>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
</UiField>
<UiField label="رمز وب‌هوک (اختیاری)">
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
</UiField>
<UiField label="آدرس Click-to-Call (اختیاری)">
<UiInput
v-model="form.clickToCallUrl"
placeholder="https://pbx.example/originate?num={number}"
/>
</UiField>
</div>
<UiButton :loading="saving" @click="createGeneric">ذخیره اتصال</UiButton>
</UiCard>
<UiCard title="اتصال‌ها">
<UiEmptyState
v-if="!loading && !genericItems.length"
title="اتصالی نیست"
description="یک اتصال برای دریافت رویداد تماس بسازید."
/>
<div v-else class="list">
<article v-for="c in genericItems" :key="c.id" class="item">
<div>
<strong>{{ c.label }}</strong>
<p class="muted">
تماس‌های ثبت‌شده {{ toFaDigits(c.callCount) }}
·
{{
c.lastEventAt
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
: 'هنوز رویدادی نیامده'
}}
</p>
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
</div>
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
</article>
</div>
<div v-if="genericWebhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک:</p>
<code>{{ genericWebhookUrl }}</code>
<UiButton
variant="secondary"
@click="copyText(genericWebhookUrl, 'آدرس وب‌هوک کپی شد.')"
>
کپی
</UiButton>
</div>
</UiCard>
</template>
<UiCard title="آخرین تماس‌ها"> <UiCard title="آخرین تماس‌ها">
<div class="call-filters">
<UiField label="جهت">
<select v-model="callFilters.direction" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="inbound">ورودی</option>
<option value="outbound">خروجی</option>
</select>
</UiField>
<UiField label="وضعیت">
<select v-model="callFilters.status" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="ringing">زنگ</option>
<option value="answered">پاسخ</option>
<option value="missed">از دست‌رفته</option>
<option value="completed">تمام‌شده</option>
<option value="failed">ناموفق</option>
</select>
</UiField>
<UiField label="خط">
<select
v-model="callFilters.lineExtension"
class="filter-select"
@change="applyCallFilters"
>
<option value="">همه خطوط</option>
<option v-for="line in lineOptions" :key="line" :value="line">
{{ line }}
</option>
</select>
</UiField>
</div>
<UiEmptyState <UiEmptyState
v-if="!calls.length" v-if="!calls.length"
title="تماسی نیست" title="تماسی نیست"
@@ -239,6 +510,7 @@ onMounted(() => {
<tr> <tr>
<th>جهت</th> <th>جهت</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>خط</th>
<th>از / به</th> <th>از / به</th>
<th>مشتری</th> <th>مشتری</th>
<th>مدت</th> <th>مدت</th>
@@ -250,6 +522,7 @@ onMounted(() => {
<tr v-for="c in calls" :key="c.id"> <tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td> <td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td> <td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td> <td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td> <td>{{ c.personName || '—' }}</td>
<td> <td>
@@ -294,6 +567,28 @@ onMounted(() => {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.tabs {
display: flex;
justify-content: flex-start;
gap: 1.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
appearance: none;
background: none;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.55rem 0.15rem;
color: #94a3b8;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tabs button.on {
color: #0d9488;
border-bottom-color: #14b8a6;
}
.hint, .hint,
.muted { .muted {
color: #64748b; color: #64748b;
@@ -312,6 +607,21 @@ onMounted(() => {
gap: 0.75rem; gap: 0.75rem;
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
} }
.call-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-select {
width: 100%;
min-height: 36px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.6rem;
background: #fff;
font: inherit;
}
.list { .list {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -404,7 +714,8 @@ th {
color: #0f766e; color: #0f766e;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.form-grid { .form-grid,
.call-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
+11
View File
@@ -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;
}
}
+20
View File
@@ -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;