9 Commits
Author SHA1 Message Date
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
32 changed files with 2568 additions and 457 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;
} }
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); 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> {
+212
View File
@@ -0,0 +1,212 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
export type InvoicePartyPosition = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
/** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */
billed: Prisma.Decimal;
/** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */
offset: Prisma.Decimal;
/** مثبت = طلب ما، منفی = بدهی ما */
net: Prisma.Decimal;
};
type Bucket = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
sales: Prisma.Decimal;
credits: Prisma.Decimal;
paid: Prisma.Decimal;
opening: Prisma.Decimal;
};
function zero() {
return new Prisma.Decimal(0);
}
function round2(value: Prisma.Decimal) {
return value.toDecimalPlaces(2);
}
/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */
export function signedInvoicePayable(invoice: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}) {
const subtotal = invoice.lineItems.reduce(
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
zero(),
);
const discount = invoice.discountAmount ?? zero();
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
const signed = round2(subtotal.minus(discount).plus(tax));
return invoice.kind === 'credit' ? signed.neg() : signed;
}
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
const connections = await prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return connections === 0;
}
/**
* ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره.
* سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود.
* اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد.
*/
export async function loadInvoicePartyPositions(
prisma: PrismaService,
organizationId: string,
): Promise<InvoicePartyPosition[]> {
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
const [invoices, openings] = await Promise.all([
prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
personId: true,
companyId: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
lineItems: { select: { quantity: true, unitPrice: true } },
payments: { select: { amount: true } },
},
}),
prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: {
personId: true,
amount: true,
person: { select: { firstName: true, lastName: true } },
},
}),
]);
const buckets = new Map<string, Bucket>();
const ensure = (
key: string,
partyKind: 'person' | 'company',
partyName: string,
) => {
const current = buckets.get(key);
if (current) return current;
const created: Bucket = {
key,
partyKind,
partyName,
sales: zero(),
credits: zero(),
paid: zero(),
opening: zero(),
};
buckets.set(key, created);
return created;
};
for (const invoice of invoices) {
const personId = invoice.personId;
const companyId = invoice.companyId;
const party = personId
? {
key: `person:${personId}`,
partyKind: 'person' as const,
partyName:
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
'مشتری',
}
: companyId
? {
key: `company:${companyId}`,
partyKind: 'company' as const,
partyName: invoice.company?.name?.trim() || 'شرکت',
}
: null;
if (!party) continue;
const bucket = ensure(party.key, party.partyKind, party.partyName);
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
bucket.paid = bucket.paid.plus(paid);
}
for (const opening of openings) {
if (opening.amount.equals(0)) continue;
const bucket = ensure(
`person:${opening.personId}`,
'person',
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
);
bucket.opening = bucket.opening.plus(opening.amount);
}
const positions: InvoicePartyPosition[] = [];
for (const bucket of buckets.values()) {
const net = round2(
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
);
if (net.equals(0)) continue;
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
const offset = round2(billed.minus(net));
positions.push({
key: bucket.key,
partyKind: bucket.partyKind,
partyName: bucket.partyName,
billed,
offset: Prisma.Decimal.max(offset, 0),
net,
});
}
return positions;
}
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */
export async function invoiceSalesInPeriod(
prisma: PrismaService,
organizationId: string,
from: Date | null,
to: Date | null,
) {
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
const invoices = await prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
issuedAt: true,
createdAt: true,
lineItems: { select: { quantity: true, unitPrice: true } },
},
});
let sales = zero();
let credits = zero();
for (const invoice of invoices) {
const when = invoice.issuedAt ?? invoice.createdAt;
if (from && when < from) continue;
if (to && when > to) continue;
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) sales = sales.plus(payable);
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
}
return {
sales: round2(sales).toFixed(2),
credits: round2(credits).toFixed(2),
net: round2(sales.minus(credits)).toFixed(2),
};
}
+39 -5
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); return new Prisma.Decimal(value ?? 0);
@@ -36,9 +37,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -88,7 +95,13 @@ export class TreasuryOpsService {
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
}); });
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -327,6 +340,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense], ['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +426,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -480,7 +500,13 @@ export class TreasuryOpsService {
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
occurredAt: Date;
account?: { name: string } | null;
}>;
}, },
fullName: string, fullName: string,
) { ) {
@@ -500,6 +526,14 @@ export class TreasuryOpsService {
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountName: item.account?.name ?? '',
})),
}; };
} }
+40 -1
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -753,7 +754,34 @@ export class TreasuryService {
current.remaining = current.remaining.plus(rawRemaining); current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current); buckets.set(key, current);
} }
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
const onDebtorSide = party.net.greaterThan(0);
if (kind === 'income' && !onDebtorSide) continue;
if (kind === 'expense' && onDebtorSide) continue;
const remaining = party.net.abs();
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
const current = buckets.get(party.key) ?? {
key: party.key,
partyKind: party.partyKind,
partyName: party.partyName,
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(remaining);
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
current.partyName = party.partyName;
}
buckets.set(party.key, current);
}
return [...buckets.values()] return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining)) .sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({ .map((row) => ({
key: row.key, key: row.key,
@@ -1092,6 +1120,17 @@ export class TreasuryService {
} }
const bookedIncome = dec(income._sum.amount); const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._sum.amount); const bookedExpense = dec(expense._sum.amount);
const invoiceSales = await invoiceSalesInPeriod(
this.prisma,
actor.organizationId,
from,
to,
);
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
else payables = payables.plus(party.net.abs());
}
const totalBalance = accounts.reduce( const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance), (sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -1101,7 +1140,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)), actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)), actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables), receivables: money(receivables),
payables: money(payables), payables: money(payables),
totalBalance: money(totalBalance), totalBalance: money(totalBalance),
+4 -2
View File
@@ -330,7 +330,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد', treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.', treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
}, },
}, },
{ {
@@ -350,7 +351,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی', treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
}, },
}, },
{ {
+463 -104
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,16 +1346,65 @@ 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>
<template v-if="isMobile">
<a-dropdown :trigger="['click']" placement="bottomLeft">
<a-button :disabled="busy">
<template #icon><MoreOutlined /></template>
بیشتر
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
key="export"
:disabled="busy || (pageBusy && busyMessage.includes('خروجی'))"
@click="exportExcel"
>
<ExportOutlined />
خروجی اکسل
</a-menu-item>
<a-menu-item key="excel" :disabled="busy">
<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"> <a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
<template #icon><ExportOutlined /></template> <template #icon><ExportOutlined /></template>
خروجی اکسل خروجی اکسل
@@ -1224,113 +1438,57 @@ onBeforeUnmount(() => {
از مخاطبین گوشی از مخاطبین گوشی
</a-button> </a-button>
</PhoneContactsImport> </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-space :size="8"> <a-flex wrap="wrap" :gap="8" align="center" flex="1 1 auto">
<FilterOutlined /> <a-button :type="!stageId ? 'primary' : 'default'" @click="setStageFilter('')">
<a-typography-text strong>فیلترها</a-typography-text> همه لیدها
</a-space> <a-typography-text
<a-select :type="!stageId ? undefined : 'secondary'"
:value="sort" :style="{ color: !stageId ? 'inherit' : undefined, marginInlineStart: '6px' }"
style="width: 11rem"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو..."
enter-button="جستجو"
style="width: 16rem; max-width: 100%"
@search="onSearch"
> >
<template #prefix><SearchOutlined /></template> {{ formatCount(totalLeadsCount) }}
</a-input-search> </a-typography-text>
<a-select </a-button>
:value="cityFilter || undefined" <a-button
allow-clear v-for="card in stageCards"
placeholder="همه مناطق" :key="card.key"
style="width: 9.5rem" :type="isStatActive(card) ? 'primary' : 'default'"
:options="cityOptions.map((c) => ({ value: c, label: c }))" :disabled="!card.stageId"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))" @click="selectStatCard(card)"
/> >
<a-select {{ card.label }}
:value="sourceFilter || undefined" <a-typography-text
allow-clear :type="isStatActive(card) ? undefined : 'secondary'"
placeholder="همه منابع" :style="{ color: isStatActive(card) ? 'inherit' : undefined, marginInlineStart: '6px' }"
style="width: 9.5rem" >
:options="sourceOptions.map((s) => ({ value: s, label: s }))" {{ formatCount(card.count) }}
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))" </a-typography-text>
/> </a-button>
<a-select
:value="stageId || undefined"
allow-clear
placeholder="همه وضعیت"
style="width: 9.5rem"
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
/>
</a-flex> </a-flex>
<a-space :size="8"> <a-space :size="8">
<TableColumnsMenu table-key="leads" align="end" /> <TableColumnsMenu table-key="leads" align="end" />
@@ -1348,9 +1506,210 @@ onBeforeUnmount(() => {
</a-radio-group> </a-radio-group>
</a-space> </a-space>
</a-flex> </a-flex>
<a-flex v-if="isMobile" vertical :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
style="width: 100%"
@search="onSearch"
>
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</a-button>
</template>
</a-input-search>
<a-badge
: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
placeholder="جستجو نام، موبایل، شهر…"
style="width: 15rem; max-width: 100%"
@search="onSearch"
>
<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
:value="assigneeSelectValue"
allow-clear
show-search
option-filter-prop="label"
placeholder="مسئول"
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
:value="followUpFilter || undefined"
allow-clear
placeholder="پیگیری"
style="width: 13rem"
: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-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>
+16 -38
View File
@@ -10,6 +10,7 @@ import NoteCategoriesManager from '@/components/notes/NoteCategoriesManager.vue'
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { appendSpokenText } from '@/lib/spoken-text'; import { appendSpokenText } from '@/lib/spoken-text';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
@@ -721,7 +722,7 @@ function syncMobileFlag() {
function onDocClick(ev: MouseEvent) { function onDocClick(ev: MouseEvent) {
const t = ev.target as HTMLElement | null; const t = ev.target as HTMLElement | null;
if (!t?.closest?.('.card-menu')) menuOpenId.value = null; if (!t?.closest?.('.card-menu, .ui-dd-panel')) menuOpenId.value = null;
} }
watch(typeFilter, () => { watch(typeFilter, () => {
@@ -962,27 +963,34 @@ onBeforeUnmount(() => {
</svg> </svg>
</span> </span>
<div class="card-menu" @click.stop> <div class="card-menu" @click.stop>
<UiDropdownMenu
:open="menuOpenId === note.id"
align="end"
@update:open="(v) => (menuOpenId = v ? note.id : null)"
>
<template #trigger="{ toggle }">
<button <button
type="button" type="button"
class="icon-btn menu-btn" class="icon-btn menu-btn"
@click="menuOpenId = menuOpenId === note.id ? null : note.id" aria-label="عملیات"
@click.stop="toggle"
> >
<UiIcon name="dots-v" :size="18" /> <UiIcon name="dots-v" :size="18" />
</button> </button>
<div v-if="menuOpenId === note.id" class="menu-pop"> </template>
<button type="button" @click="openEditor('default', note)">ویرایش</button> <button type="button" @click.stop="openEditor('default', note)">ویرایش</button>
<button type="button" @click="togglePin(note.id)"> <button type="button" @click.stop="togglePin(note.id)">
{{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }} {{ note.pinned ? 'برداشتن سنجاق' : 'سنجاق کردن' }}
</button> </button>
<button <button
v-if="(note.revisionCount ?? 0) > 0" v-if="(note.revisionCount ?? 0) > 0"
type="button" type="button"
@click="openHistory(note)" @click.stop="openHistory(note)"
> >
تاریخچه ویرایش تاریخچه ویرایش
</button> </button>
<button type="button" class="danger" @click="removeNote(note.id)">حذف</button> <button type="button" class="danger" @click.stop="removeNote(note.id)">حذف</button>
</div> </UiDropdownMenu>
</div> </div>
</div> </div>
@@ -1893,36 +1901,6 @@ onBeforeUnmount(() => {
position: relative; position: relative;
margin-inline-start: auto; margin-inline-start: auto;
} }
.menu-pop {
position: absolute;
inset-inline-end: 0;
top: 100%;
z-index: 5;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 140px;
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.menu-pop button {
border: 0;
background: transparent;
text-align: start;
padding: 0.45rem 0.65rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
color: #334155;
}
.menu-pop button:hover {
background: #f8fafc;
}
.menu-pop .danger {
color: #dc2626;
}
.card-title { .card-title {
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
+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;
+332 -21
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,9 +280,113 @@ 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>
<template v-if="tab === 'daftareshoma'">
<UiCard title="اتصال دفتر شما">
<p class="hint">
برای ثبت همه تماس‌های ورودی و خروجی فقط توکن و وب‌هوک لازم است.
توکن را از پرتال (توسعه‌دهندگان → API) بگیرید؛ بعد آدرس وب‌هوک را در
<strong>تنظیمات → هوک</strong>
با رویدادهای شروع/پایان ورودی و پایان خروجی ثبت کنید.
فیلدهای شماره مبدأ و خط فقط برای «تماس از داخل CRM» اختیاری‌اند و بعداً قابل تکمیل‌اند.
</p>
<div class="form-grid">
<UiField label="نام">
<UiInput v-model="daftaForm.label" placeholder="دفتر شما" />
</UiField>
<UiField
:label="activeDafta?.apiTokenSet ? 'توکن API (خالی = بدون تغییر)' : 'توکن API'"
>
<UiInput
v-model="daftaForm.apiToken"
type="password"
placeholder="توکن از پرتال دفتر شما"
autocomplete="off"
/>
</UiField>
<UiField label="شماره مبدأ — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.fromNumber"
placeholder="موبایل همکار برای click-to-call"
dir="ltr"
/>
</UiField>
<UiField label="خط پیش‌فرض — اختیاری (فقط تماس خروجی از CRM)">
<UiInput
v-model="daftaForm.callerExtension"
placeholder="مثلاً 02128425990"
dir="ltr"
/>
</UiField>
</div>
<UiButton :loading="saving" @click="saveDafta">
{{ activeDafta ? 'بروزرسانی اتصال' : 'ذخیره اتصال دفتر شما' }}
</UiButton>
</UiCard>
<UiCard title="اتصال‌های دفتر شما">
<UiEmptyState
v-if="!loading && !daftaItems.length"
title="اتصالی نیست"
description="توکن و مشخصات خط را ذخیره کنید."
/>
<div v-else class="list">
<article v-for="c in daftaItems" :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 class="muted">
مبدأ {{ c.fromNumber || '—' }} · خط {{ c.callerExtension || '—' }}
· توکن {{ c.apiTokenSet ? 'ثبت شده' : 'ندارد' }}
</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="daftaWebhookUrl" class="webhook">
<p class="hint">
آدرس وب‌هوک برای پرتال دفتر شما (رویدادها را با JSON به این آدرس POST کنید):
</p>
<code>{{ daftaWebhookUrl }}</code>
<UiButton
variant="secondary"
@click="copyText(daftaWebhookUrl, 'آدرس وب‌هوک دفتر شما کپی شد.')"
>
کپی
</UiButton>
</div>
</UiCard>
</template>
<template v-else>
<UiCard title="اتصال جدید"> <UiCard title="اتصال جدید">
<p class="hint"> <p class="hint">
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد. سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وب‌هوک بفرستد.
@@ -189,17 +423,17 @@ onMounted(() => {
/> />
</UiField> </UiField>
</div> </div>
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton> <UiButton :loading="saving" @click="createGeneric">ذخیره اتصال</UiButton>
</UiCard> </UiCard>
<UiCard title="اتصال‌ها"> <UiCard title="اتصال‌ها">
<UiEmptyState <UiEmptyState
v-if="!loading && !items.length" v-if="!loading && !genericItems.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 genericItems" :key="c.id" class="item">
<div> <div>
<strong>{{ c.label }}</strong> <strong>{{ c.label }}</strong>
<p class="muted"> <p class="muted">
@@ -217,14 +451,51 @@ onMounted(() => {
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton> <UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
</article> </article>
</div> </div>
<div v-if="webhookUrl" class="webhook"> <div v-if="genericWebhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک:</p> <p class="hint">آدرس وب‌هوک:</p>
<code>{{ webhookUrl }}</code> <code>{{ genericWebhookUrl }}</code>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton> <UiButton
variant="secondary"
@click="copyText(genericWebhookUrl, 'آدرس وب‌هوک کپی شد.')"
>
کپی
</UiButton>
</div> </div>
</UiCard> </UiCard>
</template>
<UiCard title="آخرین تماس‌ها"> <UiCard title="آخرین تماس‌ها">
<div class="call-filters">
<UiField label="جهت">
<select v-model="callFilters.direction" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="inbound">ورودی</option>
<option value="outbound">خروجی</option>
</select>
</UiField>
<UiField label="وضعیت">
<select v-model="callFilters.status" class="filter-select" @change="applyCallFilters">
<option value="">همه</option>
<option value="ringing">زنگ</option>
<option value="answered">پاسخ</option>
<option value="missed">از دست‌رفته</option>
<option value="completed">تمام‌شده</option>
<option value="failed">ناموفق</option>
</select>
</UiField>
<UiField label="خط">
<select
v-model="callFilters.lineExtension"
class="filter-select"
@change="applyCallFilters"
>
<option value="">همه خطوط</option>
<option v-for="line in lineOptions" :key="line" :value="line">
{{ line }}
</option>
</select>
</UiField>
</div>
<UiEmptyState <UiEmptyState
v-if="!calls.length" v-if="!calls.length"
title="تماسی نیست" title="تماسی نیست"
@@ -239,6 +510,7 @@ onMounted(() => {
<tr> <tr>
<th>جهت</th> <th>جهت</th>
<th>وضعیت</th> <th>وضعیت</th>
<th>خط</th>
<th>از / به</th> <th>از / به</th>
<th>مشتری</th> <th>مشتری</th>
<th>مدت</th> <th>مدت</th>
@@ -250,6 +522,7 @@ onMounted(() => {
<tr v-for="c in calls" :key="c.id"> <tr v-for="c in calls" :key="c.id">
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td> <td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
<td>{{ statusLabel[c.status] ?? c.status }}</td> <td>{{ statusLabel[c.status] ?? c.status }}</td>
<td dir="ltr">{{ c.lineExtension || '—' }}</td>
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td> <td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
<td>{{ c.personName || '—' }}</td> <td>{{ c.personName || '—' }}</td>
<td> <td>
@@ -294,6 +567,28 @@ onMounted(() => {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.tabs {
display: flex;
justify-content: flex-start;
gap: 1.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
appearance: none;
background: none;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.55rem 0.15rem;
color: #94a3b8;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tabs button.on {
color: #0d9488;
border-bottom-color: #14b8a6;
}
.hint, .hint,
.muted { .muted {
color: #64748b; color: #64748b;
@@ -312,6 +607,21 @@ onMounted(() => {
gap: 0.75rem; gap: 0.75rem;
margin: 0.75rem 0 1rem; margin: 0.75rem 0 1rem;
} }
.call-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-select {
width: 100%;
min-height: 36px;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.35rem 0.6rem;
background: #fff;
font: inherit;
}
.list { .list {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -404,7 +714,8 @@ th {
color: #0f766e; color: #0f766e;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.form-grid { .form-grid,
.call-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -28,9 +28,9 @@ const cards = [
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' }, { key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' }, { key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' }, { key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' }, { key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد و فروش فاکتور − هزینه' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' }, { key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'اسناد باز و مانده فاکتور' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' }, { key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه باز، حقوق و برگشتی' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' }, { key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const; ] as const;
@@ -1,41 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Col as ACol, Col as ACol,
Divider as ADivider,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Table as ATable,
Typography, Typography,
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared'; import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; type PayrollPayment = {
const ASelectOption = ASelect.Option; id: string;
amount: string;
occurredAt: string;
accountName: string;
};
type PayrollRow = { type PayrollRow = {
id: string; id: string;
userId: string;
fullName: string; fullName: string;
period: string; period: string;
baseAmount: string;
benefits: string;
bonus: string;
deductions: string;
payable: string; payable: string;
paidAmount: string;
remaining: string; remaining: string;
status: string; status: string;
payments: PayrollPayment[];
};
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'پرداخت جزئی',
settled: 'تسویه‌شده',
void: 'باطل',
}; };
const rows = ref<PayrollRow[]>([]); const rows = ref<PayrollRow[]>([]);
@@ -53,6 +72,85 @@ const form = ref({
deductions: '', deductions: '',
}); });
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined }); const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const filterUserId = ref<string | undefined>(undefined);
const filterPeriod = ref('');
const filterStatus = ref('all');
const filterMin = ref('');
const filterMax = ref('');
const statusOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'open', label: 'پرداخت‌نشده' },
{ value: 'partial', label: 'پرداخت جزئی' },
{ value: 'settled', label: 'تسویه‌شده' },
{ value: 'void', label: 'باطل' },
];
const userOptions = computed(() =>
users.value.map((user) => ({ value: user.id, label: user.fullName })),
);
const payablePreview = computed(() => {
const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0;
const bonus = parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0;
const deductions = parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0;
return base + benefits + bonus - deductions;
});
const openRows = computed(() =>
rows.value.filter((row) => row.status === 'open' || row.status === 'partial'),
);
const payOptions = computed(() =>
openRows.value.map((row) => ({
value: row.id,
label: `${row.fullName} · ${row.period} · مانده ${toman(row.remaining)}`,
})),
);
const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null);
const filtered = computed(() => {
const period = filterPeriod.value.trim();
const min = parseMoneyNumber(String(filterMin.value ?? ''), 0);
const max = parseMoneyNumber(String(filterMax.value ?? ''), 0);
return rows.value.filter((row) => {
if (filterUserId.value && row.userId !== filterUserId.value) return false;
if (period && !row.period.includes(period)) return false;
if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false;
const payable = Number(row.payable);
if (min != null && filterMin.value !== '' && payable < min) return false;
if (max != null && filterMax.value !== '' && payable > max) return false;
return true;
});
});
const moneyKeys = ['baseAmount', 'benefits', 'bonus', 'deductions', 'payable', 'paidAmount', 'remaining'] as const;
const reportColumns: TableColumnsType<PayrollRow> = [
{ title: 'پرسنل', dataIndex: 'fullName', key: 'fullName' },
{ title: 'دوره', dataIndex: 'period', key: 'period' },
{ title: 'پایه', key: 'baseAmount' },
{ title: 'مزایا', key: 'benefits' },
{ title: 'پاداش', key: 'bonus' },
{ title: 'کسورات', key: 'deductions' },
{ title: 'قابل پرداخت', key: 'payable' },
{ title: 'پرداخت‌شده', key: 'paidAmount' },
{ title: 'مانده', key: 'remaining' },
{ title: 'وضعیت', key: 'status' },
{ title: 'عملیات', key: 'action' },
];
const paymentColumns: TableColumnsType<PayrollPayment> = [
{
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount', customRender: ({ record }) => toman(record.amount) },
];
function apiMessage(e: unknown) { function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.'; if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
@@ -70,7 +168,7 @@ async function load() {
api.get<OrgUserDto[]>('/users'), api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'), api.get<FinancialAccountDto[]>('/treasury/accounts'),
]); ]);
rows.value = payroll.data; rows.value = payroll.data.map((row) => ({ ...row, payments: row.payments ?? [] }));
users.value = people.data.filter((row) => row.isActive !== false); users.value = people.data.filter((row) => row.isActive !== false);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
@@ -97,7 +195,7 @@ async function createRow() {
bonus: '', bonus: '',
deductions: '', deductions: '',
}; };
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.'; ok.value = 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمی‌شود.';
await load(); await load();
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
@@ -106,24 +204,44 @@ async function createRow() {
} }
} }
function selectPay(id: string) {
const row = rows.value.find((item) => item.id === id);
if (!row) {
pay.value = { id: '', amount: '', accountId: pay.value.accountId };
return;
}
pay.value = {
id: row.id,
amount: row.remaining,
accountId: pay.value.accountId ?? accounts.value[0]?.id,
};
}
async function payRow() { async function payRow() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
saving.value = true; saving.value = true;
const accountId = pay.value.accountId;
const keepId = pay.value.id;
try { try {
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0); const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ پرداخت معتبر نیست.'; error.value = 'مبلغ پرداخت معتبر نیست.';
return; return;
} }
await api.post(`/treasury/payroll/${pay.value.id}/pay`, { await api.post(`/treasury/payroll/${keepId}/pay`, {
amount, amount,
accountId: pay.value.accountId, accountId,
clientKey: crypto.randomUUID(), clientKey: crypto.randomUUID(),
}); });
ok.value = 'پرداخت حقوق از حساب ثبت شد.'; ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.';
pay.value = { id: '', amount: '', accountId: undefined };
await load(); await load();
const still = rows.value.find(
(row) => row.id === keepId && row.status !== 'settled' && row.status !== 'void',
);
pay.value = still
? { id: still.id, amount: still.remaining, accountId }
: { id: '', amount: '', accountId };
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
} finally { } finally {
@@ -131,14 +249,6 @@ async function payRow() {
} }
} }
function startPay(row: PayrollRow) {
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
onMounted(() => { onMounted(() => {
void load().catch(() => { void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.'; error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -151,14 +261,8 @@ onMounted(() => {
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card title="تعیین حقوق" :bordered="false">
<a-divider orientation="right">ثبت دوره</a-divider> <a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
<a-form
layout="vertical"
:model="form"
@finish="createRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
@@ -166,19 +270,18 @@ onMounted(() => {
name="userId" name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]" :rules="[{ required: true, message: 'پرسنل الزامی است' }]"
> >
<a-select show-search option-filter-prop="label" <a-select
v-model:value="form.userId" v-model:value="form.userId"
show-search
option-filter-prop="label"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب" placeholder="انتخاب"
> :options="userOptions"
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName"> />
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="دوره" label="دوره"
name="period" name="period"
@@ -211,80 +314,160 @@ onMounted(() => {
<MoneyInput v-model="form.deductions" :decimals="0" /> <MoneyInput v-model="form.deductions" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="16">
<a-form-item label="قابل پرداخت">
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
</a-form-item>
</a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button> <a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form> </a-form>
</a-card>
<template v-if="pay.id"> <a-card title="پرداخت" :bordered="false">
<a-divider orientation="right">پرداخت حقوق</a-divider> <a-flex vertical :gap="16">
<a-typography-text type="secondary">
می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
</a-typography-text>
<a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." />
<a-form <a-form
v-else
layout="vertical" layout="vertical"
:model="pay" :model="pay"
@finish="payRow" @finish="payRow"
@finishFailed="onFinishFailed" @finishFailed="onFinishFailed"
> >
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="12">
<a-form-item <a-form-item
label="مبلغ" label="دورهٔ حقوق"
name="id"
:rules="[{ required: true, message: 'دوره الزامی است' }]"
>
<a-select
:value="pay.id || undefined"
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="انتخاب پرسنل و دوره"
:options="payOptions"
@update:value="selectPay(String($event ?? ''))"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item
label="مبلغ این مرحله"
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="pay.amount" :decimals="0" /> <MoneyInput v-model="pay.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="6">
<a-form-item <a-form-item
label="حساب" label="حساب"
name="accountId" name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]" :rules="[{ required: true, message: 'حساب الزامی است' }]"
> >
<a-select show-search option-filter-prop="label" <a-select
v-model:value="pay.accountId" v-model:value="pay.accountId"
show-search
option-filter-prop="label"
allow-clear allow-clear
style="width: 100%" style="width: 100%"
placeholder="انتخاب حساب" placeholder="انتخاب حساب"
> :options="accounts.map((account) => ({ value: account.id, label: account.name }))"
<a-select-option />
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button> <a-flex v-if="selectedPay" :gap="16" wrap="wrap">
<a-typography-text>قابل پرداخت {{ toman(selectedPay.payable) }}</a-typography-text>
<a-typography-text>پرداخت‌شده {{ toman(selectedPay.paidAmount) }}</a-typography-text>
<a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text>
</a-flex>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
ثبت پرداخت
</a-button>
</a-form> </a-form>
</template>
<a-divider orientation="right">فهرست</a-divider> <a-table
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." /> v-if="selectedPay?.payments.length"
<a-list v-else item-layout="horizontal" :data-source="rows"> :columns="paymentColumns"
<template #renderItem="{ item }"> :data-source="selectedPay.payments"
<a-list-item> :pagination="false"
<template #actions> row-key="id"
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text> />
</a-flex>
</a-card>
<a-card title="گزارش حقوق" :bordered="false">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="پرسنل">
<a-select
v-model:value="filterUserId"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="همه"
:options="userOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره">
<a-input v-model:value="filterPeriod" placeholder="مثلاً ۱۴۰۵-۰۷" allow-clear />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت">
<a-select v-model:value="filterStatus" style="width: 100%" :options="statusOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداقل قابل پرداخت">
<MoneyInput v-model="filterMin" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداکثر قابل پرداخت">
<MoneyInput v-model="filterMax" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-table
:columns="reportColumns"
:data-source="filtered"
row-key="id"
:scroll="{ x: 'max-content' }"
:locale="{ emptyText: 'رکوردی با این فیلتر نیست.' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
{{ statusLabel[record.status] ?? record.status }}
</template>
<template v-else-if="column.key === 'action'">
<a-button <a-button
v-if="item.status !== 'settled' && item.status !== 'void'" v-if="record.status === 'open' || record.status === 'partial'"
type="link" type="link"
@click="startPay(item)" @click="selectPay(record.id)"
> >
پرداخت پرداخت
</a-button> </a-button>
</template> </template>
<a-list-item-meta> <template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
<template #title>{{ item.fullName }}</template> {{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
<template #description>
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
</template> </template>
</a-list-item-meta> <template v-else>
</a-list-item> {{ column.dataIndex ? record[column.dataIndex as 'fullName' | 'period'] : '' }}
</template> </template>
</a-list> </template>
</a-table>
</a-card> </a-card>
</a-flex> </a-flex>
</template> </template>
@@ -28,6 +28,8 @@ const ATabPane = ATabs.TabPane;
type Period = { type Period = {
bookedIncome: string; bookedIncome: string;
invoiceSales: string;
invoiceCredits: string;
bookedExpense: string; bookedExpense: string;
operatingProfit: string; operatingProfit: string;
cashIn: string; cashIn: string;
@@ -61,6 +63,8 @@ const kindLabel: Record<string, string> = {
const lines = [ const lines = [
['درآمد ثبت‌شده', 'bookedIncome'], ['درآمد ثبت‌شده', 'bookedIncome'],
['فروش فاکتور', 'invoiceSales'],
['فاکتور برگشتی', 'invoiceCredits'],
['هزینه ثبت‌شده', 'bookedExpense'], ['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'], ['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'], ['ورود وجه', 'cashIn'],
@@ -79,8 +83,8 @@ const metricRows = computed<MetricRow[]>(() => {
return lines.map(([label, key]) => ({ return lines.map(([label, key]) => ({
key, key,
label, label,
current: toman(current.value![key]), current: toman(current.value![key] || '0'),
previous: toman(previous.value![key]), previous: toman(previous.value![key] || '0'),
})); }));
}); });
@@ -244,6 +248,9 @@ onMounted(() => {
<a-tab-pane key="debtors" tab="بدهکاران"> <a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }} {{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
@@ -259,6 +266,9 @@ onMounted(() => {
<a-tab-pane key="creditors" tab="بستانکاران"> <a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }} {{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
+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;