Compare commits
17
Commits
6c6e32e099
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0998a1ec37 | ||
|
|
4979aa8b75 | ||
|
|
34d3689448 | ||
|
|
1e0b638a19 | ||
|
|
3db9c1c0fe | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 |
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
|
||||
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
|
||||
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
|
||||
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
|
||||
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
|
||||
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
|
||||
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||
|
||||
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "DataFix" (
|
||||
"key" TEXT NOT NULL,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
@@ -295,6 +295,7 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||
@@ -524,6 +525,7 @@ model Company {
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
financialDocuments FinancialDocument[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
@@ -1841,6 +1843,13 @@ model AccountingConnection {
|
||||
lastHealthAt DateTime?
|
||||
lastHealthOk Boolean?
|
||||
lastError String?
|
||||
/// ساعتهای بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
|
||||
customerSyncHours Int[] @default([])
|
||||
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
|
||||
customerSyncLastSlot String?
|
||||
customerSyncLastRunAt DateTime?
|
||||
customerSyncLastSummary String?
|
||||
customerSyncLastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
personLinks PersonAccountingLink[]
|
||||
@@ -2330,6 +2339,12 @@ model FinancialDocument {
|
||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
userId String?
|
||||
partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull)
|
||||
/// person | company | user | other
|
||||
partyKind String?
|
||||
partyName String?
|
||||
accountId String?
|
||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||
@@ -2350,6 +2365,8 @@ model FinancialDocument {
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
@@ -3769,3 +3786,9 @@ model AttendanceEvent {
|
||||
@@index([organizationId, userId, at])
|
||||
@@unique([organizationId, userId, kind, sessionStartedAt])
|
||||
}
|
||||
|
||||
/// علامت یکبار اجرای اصلاح داده
|
||||
model DataFix {
|
||||
key String @id
|
||||
doneAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatClock,
|
||||
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
const resolvePerson = async (row: Record<string, string>) => {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const name = (row.personName ?? '').trim().toLowerCase();
|
||||
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
|
||||
if (!key) return null;
|
||||
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
CustomerGroupsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
|
||||
|
||||
const personSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
gender: true,
|
||||
showGenderInName: true,
|
||||
previousFirstName: true,
|
||||
previousLastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
position: true,
|
||||
website: true,
|
||||
instagram: true,
|
||||
birthDate: true,
|
||||
referrer: true,
|
||||
addresses: true,
|
||||
notes: true,
|
||||
tags: true,
|
||||
companyId: true,
|
||||
customFields: true,
|
||||
importSource: true,
|
||||
sourceRef: true,
|
||||
previousImportSource: true,
|
||||
previousSourceRef: true,
|
||||
loyaltyPoints: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
} satisfies Prisma.PersonSelect;
|
||||
|
||||
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
|
||||
|
||||
function blank(value: string | null | undefined) {
|
||||
return !value?.trim();
|
||||
}
|
||||
|
||||
function asStrings(value: Prisma.JsonValue): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
|
||||
const notes = [keeper.notes?.trim() || ''];
|
||||
let email = keeper.email;
|
||||
let phone = storedPhoneNumber(keeper.phone);
|
||||
let position = keeper.position;
|
||||
let website = keeper.website;
|
||||
let instagram = keeper.instagram;
|
||||
let birthDate = keeper.birthDate;
|
||||
let referrer = keeper.referrer;
|
||||
let companyId = keeper.companyId;
|
||||
let gender = keeper.gender;
|
||||
let showGenderInName = keeper.showGenderInName;
|
||||
let previousFirstName = keeper.previousFirstName;
|
||||
let previousLastName = keeper.previousLastName;
|
||||
let previousImportSource = keeper.previousImportSource;
|
||||
let previousSourceRef = keeper.previousSourceRef;
|
||||
let discLabel = keeper.discLabel;
|
||||
let discReliable = keeper.discReliable;
|
||||
let discAssessedAt = keeper.discAssessedAt;
|
||||
let loyaltyPoints = keeper.loyaltyPoints;
|
||||
const addresses = asStrings(keeper.addresses);
|
||||
const tags = new Set(keeper.tags);
|
||||
const customFields = asObject(keeper.customFields);
|
||||
|
||||
for (const extra of extras) {
|
||||
const name = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
notes.push(
|
||||
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
|
||||
);
|
||||
if (blank(email) && extra.email) email = extra.email;
|
||||
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
|
||||
if (blank(position) && extra.position) position = extra.position;
|
||||
if (blank(website) && extra.website) website = extra.website;
|
||||
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
|
||||
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
|
||||
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
|
||||
if (!companyId && extra.companyId) companyId = extra.companyId;
|
||||
if (gender === 'none' && extra.gender !== 'none') {
|
||||
gender = extra.gender;
|
||||
showGenderInName = extra.showGenderInName;
|
||||
}
|
||||
if (blank(previousFirstName) && extra.previousFirstName) {
|
||||
previousFirstName = extra.previousFirstName;
|
||||
}
|
||||
if (blank(previousLastName) && extra.previousLastName) {
|
||||
previousLastName = extra.previousLastName;
|
||||
}
|
||||
if (
|
||||
blank(previousImportSource) &&
|
||||
extra.importSource &&
|
||||
extra.importSource !== keeper.importSource
|
||||
) {
|
||||
previousImportSource = extra.importSource;
|
||||
previousSourceRef = extra.sourceRef;
|
||||
}
|
||||
if (
|
||||
keeper.importSource === 'tahesab' &&
|
||||
blank(previousFirstName) &&
|
||||
blank(previousLastName)
|
||||
) {
|
||||
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
|
||||
if (extraName && extraName !== keeperName) {
|
||||
previousFirstName = extra.firstName || null;
|
||||
previousLastName = extra.lastName || null;
|
||||
}
|
||||
}
|
||||
if (!discLabel && extra.discLabel) {
|
||||
discLabel = extra.discLabel;
|
||||
discReliable = extra.discReliable;
|
||||
discAssessedAt = extra.discAssessedAt;
|
||||
}
|
||||
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
|
||||
for (const address of asStrings(extra.addresses)) {
|
||||
if (!addresses.includes(address)) addresses.push(address);
|
||||
}
|
||||
for (const tag of extra.tags) tags.add(tag);
|
||||
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
|
||||
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mobile: canon,
|
||||
email,
|
||||
phone,
|
||||
position,
|
||||
website,
|
||||
instagram,
|
||||
birthDate,
|
||||
referrer,
|
||||
companyId,
|
||||
gender,
|
||||
showGenderInName,
|
||||
previousFirstName,
|
||||
previousLastName,
|
||||
previousImportSource,
|
||||
previousSourceRef,
|
||||
discLabel,
|
||||
discReliable,
|
||||
discAssessedAt,
|
||||
loyaltyPoints,
|
||||
addresses,
|
||||
tags: [...tags],
|
||||
customFields: customFields as Prisma.InputJsonValue,
|
||||
notes: notes.filter(Boolean).join('\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
|
||||
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND column_name = 'personId'
|
||||
`;
|
||||
return rows
|
||||
.map((row) => row.table_name)
|
||||
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
|
||||
}
|
||||
|
||||
async function moveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
tables: string[],
|
||||
keeperId: string,
|
||||
dupId: string,
|
||||
) {
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonGroupLink" AS d
|
||||
USING "PersonGroupLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."groupId" = k."groupId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonAccountingLink" AS d
|
||||
USING "PersonAccountingLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."connectionId" = k."connectionId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PartyOpeningBalance"
|
||||
WHERE "personId" = ${dupId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const table of tables) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
|
||||
keeperId,
|
||||
dupId,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "MontifyLink"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Notification"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "GamificationTransaction"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FinancialAuditLog"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function mergeSameMobilePeople(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
apply: boolean,
|
||||
) {
|
||||
const people = await prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
select: personSelect,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, PersonRow[]>();
|
||||
for (const person of people) {
|
||||
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
if (!key) continue;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(person);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
|
||||
const preview = duplicateGroups.map(([mobile, list]) => ({
|
||||
mobile,
|
||||
people: list.map((person) => ({
|
||||
id: person.id,
|
||||
stored: person.mobile,
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!apply) {
|
||||
const needsRewrite = people.filter((person) => {
|
||||
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
||||
}).length;
|
||||
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
||||
}
|
||||
|
||||
const tables = await personIdTables(prisma);
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [canon, list] of groups) {
|
||||
const ordered = [...list].sort((a, b) => {
|
||||
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
|
||||
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
|
||||
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
|
||||
});
|
||||
const [keeper, ...extras] = ordered;
|
||||
if (!keeper) continue;
|
||||
if (!extras.length) {
|
||||
const phoneCanon = canonicalIranMobile(keeper.phone);
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
if (keeper.mobile !== canon) data.mobile = canon;
|
||||
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
||||
if (Object.keys(data).length) {
|
||||
await prisma.person.update({ where: { id: keeper.id }, data });
|
||||
rewritten += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.person.update({
|
||||
where: { id: keeper.id },
|
||||
data: mergedFields(keeper, extras, canon),
|
||||
});
|
||||
for (const extra of extras) {
|
||||
await moveLinks(tx, tables, keeper.id, extra.id);
|
||||
await tx.person.delete({ where: { id: extra.id } });
|
||||
merged += 1;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(
|
||||
`merge mobile ${canon} failed`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
|
||||
}
|
||||
|
||||
function loadEnv(file: string) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
|
||||
if (direct) {
|
||||
loadEnv(resolve(__dirname, '../../.env'));
|
||||
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
|
||||
const apply = process.argv.includes('--apply');
|
||||
if (!orgId) {
|
||||
console.error('org id required: --org=<id>');
|
||||
process.exit(1);
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
mergeSameMobilePeople(prisma, orgId, apply)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify({
|
||||
apply,
|
||||
merged: result.merged,
|
||||
rewritten: result.rewritten,
|
||||
needsRewrite: result.needsRewrite,
|
||||
groupCount: result.groups.length,
|
||||
groups: result.groups,
|
||||
}, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||
export function canonicalIranMobile(value?: string | null): string | null {
|
||||
const n = normalizePhone(value);
|
||||
if (!n) return null;
|
||||
let d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||
const canon = canonicalIranMobile(value);
|
||||
if (!canon) return [];
|
||||
const core = canon.slice(1);
|
||||
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||
}
|
||||
|
||||
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||
const keys = iranMobileLookupKeys(value);
|
||||
if (keys.length) return keys;
|
||||
const n = normalizePhone(value);
|
||||
return n ? [n] : [];
|
||||
}
|
||||
|
||||
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||
export function storedPhoneNumber(value?: string | null): string | null {
|
||||
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from './normalize';
|
||||
import {
|
||||
asAddresses,
|
||||
asCustomFields,
|
||||
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
|
||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||
) {
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (input.mobile) or.push({ mobile: input.mobile });
|
||||
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (input.email) or.push({ email: input.email });
|
||||
if (!or.length) return;
|
||||
await this.prisma.person.updateMany({
|
||||
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
phone: storedPhoneNumber(dto.phone),
|
||||
mobile: storedPhoneNumber(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
website: emptyToNull(dto.website),
|
||||
instagram: emptyToNull(dto.instagram),
|
||||
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||
@@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit {
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||
|
||||
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||
|
||||
@Injectable()
|
||||
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
void this.run();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||
SELECT 1 AS ok
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND lower(table_name) = 'person'
|
||||
AND lower(column_name) = 'gender'
|
||||
`;
|
||||
if (!ready.length) {
|
||||
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (done.length) return;
|
||||
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||
try {
|
||||
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (again.length) return;
|
||||
|
||||
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||
merged += result.merged;
|
||||
rewritten += result.rewritten;
|
||||
failed += result.failed;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.error(
|
||||
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed) return;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||
ON CONFLICT ("key") DO NOTHING
|
||||
`;
|
||||
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||
} finally {
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||
import { MontifyConnectionsService } from './connections.service';
|
||||
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function mobileVariants(mobile: string): string[] {
|
||||
const digits = mobile.replace(/\D/g, '');
|
||||
let core = digits;
|
||||
if (core.startsWith('0098')) core = core.slice(4);
|
||||
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
|
||||
else if (core.startsWith('0')) core = core.slice(1);
|
||||
if (!core) return [mobile];
|
||||
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
|
||||
return phoneIdentityKeys(mobile);
|
||||
}
|
||||
|
||||
function formatAddress(a: MontifyAddress): string | null {
|
||||
@@ -417,7 +415,7 @@ export class MontifySyncService {
|
||||
}> {
|
||||
const externalId = String(c.id);
|
||||
const { firstName, lastName } = splitName(c);
|
||||
const mobile = normalizePhone(c.phone ?? null);
|
||||
const mobile = storedPhoneNumber(c.phone ?? null);
|
||||
const email = normalizeEmail(c.email ?? null);
|
||||
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
|
||||
const profile: CustomerProfile = {
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
function canonicalMobileUpdate(
|
||||
incoming: string | null,
|
||||
current: { mobile: string | null; phone: string | null },
|
||||
): { mobile?: string; phone?: string } {
|
||||
const mobile =
|
||||
canonicalIranMobile(incoming) ??
|
||||
canonicalIranMobile(current.mobile) ??
|
||||
canonicalIranMobile(current.phone);
|
||||
if (!mobile) return {};
|
||||
const phone = canonicalIranMobile(current.phone);
|
||||
return phone ? { mobile, phone } : { mobile };
|
||||
}
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
const LIST_PAGE_SIZE = 2000;
|
||||
const LIST_TIMEOUT_MS = 55_000;
|
||||
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = tel || null;
|
||||
const mobile = storedPhoneNumber(tel);
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
let preserveExistingLink = false;
|
||||
if (personId) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const prevFirst = existing.firstName;
|
||||
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...(mobile
|
||||
? {
|
||||
mobile: existing.mobile || mobile,
|
||||
phone: existing.phone || mobile,
|
||||
}
|
||||
: {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته میشود؛
|
||||
// وگرنه upsert لینک، کد قبلی را جابهجا و آن مشتری را دوباره «واردنشده» میکند.
|
||||
const byMobile = mobile
|
||||
? await this.prisma.person.findFirst({
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
||||
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
accountingLinks: { none: { connectionId: dto.connectionId } },
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
include: {
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
: [];
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
|
||||
: {}),
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
@@ -669,6 +696,32 @@ export class TahesabPersonLinkService {
|
||||
}
|
||||
}
|
||||
|
||||
if (preserveExistingLink) {
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { personId, link: this.linkDto(existing) };
|
||||
}
|
||||
|
||||
const link = await this.prisma.personAccountingLink.upsert({
|
||||
where: {
|
||||
personId_connectionId: {
|
||||
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...(!existing.birthDate && pickBirthDate(row)
|
||||
? { birthDate: pickBirthDate(row) }
|
||||
: {}),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
||||
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
||||
*/
|
||||
async syncAllCustomers(organizationId: string, connectionId: string) {
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
|
||||
while (emptyStreak < 2 && page <= maxPages) {
|
||||
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
||||
try {
|
||||
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
||||
errors.push(message);
|
||||
break;
|
||||
}
|
||||
if (!listed.rows.length) {
|
||||
emptyStreak += 1;
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
emptyStreak = 0;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null && code >= 1) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId,
|
||||
moshtariCode: { in: [...rowsByCode.keys()] },
|
||||
},
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
||||
for (const [code, row] of rowsByCode) {
|
||||
try {
|
||||
const personId = linkedByCode.get(code);
|
||||
if (personId) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
connectionId,
|
||||
personId,
|
||||
code,
|
||||
row,
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push(err instanceof Error ? err.message : 'خطا');
|
||||
}
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (page > maxPages && emptyStreak < 2) {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
failed,
|
||||
summary,
|
||||
error: errors.length ? errors.join('؛ ') : null,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { normalizePhone } from '../../contacts/normalize';
|
||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
|
||||
function digitsOnly(value: string | null | undefined): string {
|
||||
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
|
||||
variants.add(`0${d}`);
|
||||
variants.add(`98${d}`);
|
||||
}
|
||||
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
@@ -374,7 +378,8 @@ export class WooSyncService {
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const mobile = storedPhoneNumber(billing.phone ?? null);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
@@ -387,7 +392,9 @@ export class WooSyncService {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
...(mobileKeys.length
|
||||
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../contacts/normalize';
|
||||
import { LeadStagesService } from '../leads/lead-stages.service';
|
||||
|
||||
@@ -458,7 +461,10 @@ export class LeadFormsService {
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: form.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
OR: [
|
||||
{ mobile: { in: phoneIdentityKeys(mobile) } },
|
||||
{ phone: { in: phoneIdentityKeys(mobile) } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
@@ -641,13 +647,16 @@ export class LeadFormsService {
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
const mobile = storedPhoneNumber(mapped.mobile);
|
||||
const phone = storedPhoneNumber(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
|
||||
}
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function normalizeDuplicateMode(raw?: string | null): string {
|
||||
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
collectAddresses,
|
||||
toJsonAddresses,
|
||||
} from '../common/custom-fields';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
} from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
@@ -55,13 +60,20 @@ const LOGO_EXT: Record<string, string> = {
|
||||
};
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function personWithMobile(organizationId: string, mobile: string): Prisma.PersonWhereInput {
|
||||
const keys = phoneIdentityKeys(mobile);
|
||||
return {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
|
||||
};
|
||||
}
|
||||
|
||||
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
|
||||
@@ -323,10 +335,7 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, mobile),
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (person) {
|
||||
@@ -966,10 +975,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
|
||||
},
|
||||
where: personWithMobile(actor.organizationId, lead.mobile),
|
||||
});
|
||||
let createdPerson = false;
|
||||
|
||||
@@ -996,7 +1002,7 @@ export class LeadsService {
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
mobile: lead.mobile,
|
||||
mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
|
||||
phone: lead.phone,
|
||||
instagram: lead.instagram,
|
||||
birthDate: lead.birthDate,
|
||||
@@ -1170,6 +1176,95 @@ export class LeadsService {
|
||||
};
|
||||
}
|
||||
|
||||
async createFromExternal(input: {
|
||||
organizationId: string;
|
||||
siteName: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
mobile: string;
|
||||
storeName?: string | null;
|
||||
source?: string | null;
|
||||
}): Promise<{
|
||||
ok: true;
|
||||
created: boolean;
|
||||
duplicate: boolean;
|
||||
leadId: string | null;
|
||||
}> {
|
||||
const mobile = mobileKey(input.mobile);
|
||||
if (!mobile || !/^09\d{9}$/.test(mobile)) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const contactName =
|
||||
`${input.firstName?.trim() ?? ''} ${input.lastName?.trim() ?? ''}`.trim() || null;
|
||||
const storeName = input.storeName?.trim() ?? '';
|
||||
const source = emptyToNull(input.source);
|
||||
const title = storeName || contactName || mobile;
|
||||
const siteName = input.siteName.trim() || 'سایت بیرونی';
|
||||
|
||||
const existing = await this.prisma.lead.findFirst({
|
||||
where: { organizationId: input.organizationId, mobile, archivedAt: null },
|
||||
select: { id: true, source: true, contactName: true },
|
||||
});
|
||||
if (existing) {
|
||||
const patch: Prisma.LeadUpdateInput = {};
|
||||
if (!existing.source && source) patch.source = source;
|
||||
if (!existing.contactName && contactName) patch.contactName = contactName;
|
||||
if (Object.keys(patch).length) {
|
||||
await this.prisma.lead.update({ where: { id: existing.id }, data: patch });
|
||||
}
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: existing.id,
|
||||
type: 'note',
|
||||
title: 'درخواست دوباره از سایت',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: false, duplicate: true, leadId: existing.id };
|
||||
}
|
||||
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: personWithMobile(input.organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (person) {
|
||||
return { ok: true, created: false, duplicate: true, leadId: null };
|
||||
}
|
||||
|
||||
await this.stages.ensureDefaults(input.organizationId);
|
||||
const stages = await this.stages.list(input.organizationId);
|
||||
const stageId = stages[0]?.id;
|
||||
if (!stageId) throw new BadRequestException('مرحلهای تعریف نشده');
|
||||
|
||||
const lead = await this.prisma.lead.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
title,
|
||||
contactName,
|
||||
mobile,
|
||||
source,
|
||||
notes: `ثبت از سایت: ${siteName}`,
|
||||
stageId,
|
||||
},
|
||||
});
|
||||
await this.prisma.leadEvent.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
leadId: lead.id,
|
||||
type: 'created',
|
||||
title: 'لید از سایت ثبت شد',
|
||||
body: [`سایت: ${siteName}`, source ? `منبع: ${source}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
},
|
||||
});
|
||||
return { ok: true, created: true, duplicate: false, leadId: lead.id };
|
||||
}
|
||||
|
||||
async createFromImportRow(
|
||||
organizationId: string,
|
||||
createdById: string | null,
|
||||
@@ -1212,10 +1307,7 @@ export class LeadsService {
|
||||
}
|
||||
|
||||
const existingPerson = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
},
|
||||
where: personWithMobile(organizationId, mobile),
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingPerson) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { dealPriorWhere } from '../common/prior-archive';
|
||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -708,9 +708,9 @@ export class DealsExcelService {
|
||||
): Promise<string | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const code = cleanCode(row.personCode);
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
|
||||
const validMobile = mobileCore.length >= 8 ? mobile : null;
|
||||
const mobile = storedPhoneNumber(row.personMobile);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
|
||||
const first = (row.personFirstName ?? '').trim();
|
||||
const last = (row.personName ?? '').trim();
|
||||
const fullName = [first, last].filter(Boolean).join(' ');
|
||||
@@ -731,7 +731,10 @@ export class DealsExcelService {
|
||||
}
|
||||
if (!found && validMobile) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: { organizationId: orgId, mobile: { contains: mobileCore } },
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import type { PortalActor } from './portal-auth';
|
||||
|
||||
@@ -62,7 +63,7 @@ export class PortalService {
|
||||
mobileRaw: string,
|
||||
): Promise<PortalRequestOtpResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
@@ -123,7 +124,8 @@ export class PortalService {
|
||||
codeRaw: string,
|
||||
): Promise<PortalLoginResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const code = codeRaw.trim();
|
||||
if (!mobile || !code) {
|
||||
throw new BadRequestException('موبایل یا کد ناقص است');
|
||||
@@ -148,10 +150,15 @@ export class PortalService {
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: { organizationId: org.id, mobile },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
let person = mobileKeys.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
if (!person) {
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { projectCardAccessWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: string;
|
||||
role: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyName?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: auth.organizationId,
|
||||
role: auth.role,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
apiKeyName: auth.name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ApiKeysService {
|
||||
|
||||
async authenticate(
|
||||
rawKey: string,
|
||||
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
|
||||
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
|
||||
const key = rawKey.trim();
|
||||
if (!key.startsWith('mk_') || key.length < 20) {
|
||||
throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
@@ -71,7 +71,7 @@ export class ApiKeysService {
|
||||
const keyHash = this.hashKey(key);
|
||||
const row = await this.prisma.organizationApiKey.findFirst({
|
||||
where: { keyHash, revokedAt: null },
|
||||
select: { id: true, organizationId: true },
|
||||
select: { id: true, organizationId: true, name: true },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
|
||||
@@ -83,6 +83,7 @@ export class ApiKeysService {
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
apiKeyId: row.id,
|
||||
name: row.name,
|
||||
role: 'owner',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
|
||||
class CreateExternalLeadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
storeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const LEAD_RATE_WINDOW_MS = 60_000;
|
||||
const LEAD_RATE_MAX = 60;
|
||||
const leadHits = new Map<string, number[]>();
|
||||
|
||||
class ListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -35,7 +72,36 @@ class ListQueryDto {
|
||||
@Controller('public/v1')
|
||||
@UseGuards(ApiKeyAuthGuard)
|
||||
export class PublicApiV1Controller {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly leads: LeadsService,
|
||||
) {}
|
||||
|
||||
@Post('leads')
|
||||
@HttpCode(200)
|
||||
createLead(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
|
||||
@Body() body: CreateExternalLeadDto,
|
||||
) {
|
||||
const bucket = user.apiKeyId || user.organizationId;
|
||||
const now = Date.now();
|
||||
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
|
||||
if (prev.length >= LEAD_RATE_MAX) {
|
||||
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
prev.push(now);
|
||||
leadHits.set(bucket, prev);
|
||||
return this.leads.createFromExternal({
|
||||
organizationId: user.organizationId,
|
||||
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
mobile: body.mobile,
|
||||
storeName: body.storeName,
|
||||
source: body.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('people')
|
||||
async listPeople(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, LeadsModule],
|
||||
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
|
||||
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
|
||||
exports: [ApiKeysService, WebhooksService],
|
||||
|
||||
@@ -185,6 +185,7 @@ export function toRoutineDto(
|
||||
entityLink: normalizeEntityLink(row.entityLink),
|
||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||
isActive: row.isActive,
|
||||
createdById: row.createdById,
|
||||
recurrenceLabel: describeRecurrence(rule),
|
||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||
executionTotal: execution.total,
|
||||
|
||||
@@ -200,8 +200,7 @@ export class RoutinesService {
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -216,10 +215,9 @@ export class RoutinesService {
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
@@ -232,9 +230,8 @@ export class RoutinesService {
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
@@ -251,9 +248,8 @@ export class RoutinesService {
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
@@ -268,9 +264,8 @@ export class RoutinesService {
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
this.assertCanWriteRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
@@ -295,9 +290,8 @@ export class RoutinesService {
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
@@ -345,6 +339,27 @@ export class RoutinesService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** عضو عادی فقط روتینی را مینویسد که خودش برای خودش ساخته است. */
|
||||
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
|
||||
if (scope.isManager) return dto;
|
||||
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
|
||||
throw new ForbiddenException('فقط میتوانید برای خودتان روتین بسازید');
|
||||
}
|
||||
return { ...dto, assigneeId: actor.userId, teamId: null };
|
||||
}
|
||||
|
||||
private assertCanWriteRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.isManager) {
|
||||
this.assertCanManageRoutine(scope, row);
|
||||
return;
|
||||
}
|
||||
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
|
||||
throw new ForbiddenException('فقط روتینی که برای خودتان ساختهاید را میتوانید تغییر دهید');
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
async reportExcel(
|
||||
actor: Actor,
|
||||
from?: string,
|
||||
to?: string,
|
||||
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
|
||||
) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
if (parties) {
|
||||
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافتشده');
|
||||
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداختشده');
|
||||
}
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private addPartySheet(
|
||||
book: ExcelJS.Workbook,
|
||||
title: string,
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
settledLabel: string,
|
||||
) {
|
||||
const sheet = book.addWorksheet(title);
|
||||
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
|
||||
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
|
||||
let amount = new Prisma.Decimal(0);
|
||||
let settled = new Prisma.Decimal(0);
|
||||
let remaining = new Prisma.Decimal(0);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
]);
|
||||
amount = amount.plus(row.amount);
|
||||
settled = settled.plus(row.settledAmount);
|
||||
remaining = remaining.plus(row.remaining);
|
||||
}
|
||||
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -92,6 +106,29 @@ export class TreasuryController {
|
||||
return this.treasury.transfer(user, body);
|
||||
}
|
||||
|
||||
@Get('parties')
|
||||
parties(@CurrentUser() user: Actor, @Query('q') q?: string) {
|
||||
return this.treasury.searchParties(user, q);
|
||||
}
|
||||
|
||||
@Post('parties/person')
|
||||
createPartyPerson(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() body: { firstName?: string; lastName?: string },
|
||||
) {
|
||||
return this.treasury.createPartyPerson(user, body);
|
||||
}
|
||||
|
||||
@Post('parties/company')
|
||||
createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) {
|
||||
return this.treasury.createPartyCompany(user, body);
|
||||
}
|
||||
|
||||
@Get('party-balances')
|
||||
partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) {
|
||||
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
|
||||
}
|
||||
|
||||
@Get('documents')
|
||||
documents(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -232,7 +269,11 @@ export class TreasuryController {
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
const [debtors, creditors] = await Promise.all([
|
||||
this.treasury.partyBalances(user, 'income'),
|
||||
this.treasury.partyBalances(user, 'expense'),
|
||||
]);
|
||||
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ContactsModule } from '../contacts/contacts.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
imports: [AuthModule, NotificationsModule, ContactsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
|
||||
@@ -13,8 +13,14 @@ import type {
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialMovementDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryDashboardDto,
|
||||
TreasuryPartyBalanceDto,
|
||||
TreasuryPartySearchDto,
|
||||
} from '@mento/shared';
|
||||
import { CompaniesService } from '../contacts/companies.service';
|
||||
import { PeopleService } from '../contacts/people.service';
|
||||
import { dateToJalali } from '../common/jalali';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
@@ -65,6 +71,8 @@ export class TreasuryService {
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly storage: StorageQuotaService,
|
||||
private readonly people: PeopleService,
|
||||
private readonly companies: CompaniesService,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
@@ -112,19 +120,68 @@ export class TreasuryService {
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; isActive?: boolean },
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
const name = input.name == null ? current.name : input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
name,
|
||||
...(input.isActive == null ? {} : { isActive: input.isActive }),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
await this.prisma.financialCategory.delete({ where: { id: current.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
@@ -462,7 +519,10 @@ export class TreasuryService {
|
||||
bookedAt?: string;
|
||||
dueAt?: string | null;
|
||||
categoryId?: string | null;
|
||||
partyKind?: FinancialPartyKind | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
partyName?: string | null;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
@@ -476,9 +536,13 @@ export class TreasuryService {
|
||||
const amount = dec(input.amount);
|
||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||
const party = await this.resolveParty(actor, input);
|
||||
const settle = input.settleAmount == null || input.settleAmount === ''
|
||||
? new Prisma.Decimal(0)
|
||||
: dec(input.settleAmount);
|
||||
if (party.partyKind === 'other' && !settle.equals(amount)) {
|
||||
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
|
||||
}
|
||||
if (settle.isNegative() || settle.greaterThan(amount)) {
|
||||
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||
}
|
||||
@@ -497,6 +561,13 @@ export class TreasuryService {
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
input.kind,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -506,12 +577,15 @@ export class TreasuryService {
|
||||
bookedAt,
|
||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||
categoryId: input.categoryId || null,
|
||||
personId: input.personId || null,
|
||||
partyName: input.partyName?.trim() || null,
|
||||
personId: party.personId,
|
||||
companyId: party.companyId,
|
||||
userId: party.userId,
|
||||
partyKind: party.partyKind,
|
||||
partyName: party.partyName,
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
@@ -527,7 +601,7 @@ export class TreasuryService {
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
@@ -540,6 +614,221 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
|
||||
private async nextDocumentReference(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
kind: 'income' | 'expense',
|
||||
bookedAtInput: string | undefined,
|
||||
bookedAt: Date,
|
||||
) {
|
||||
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const when = match
|
||||
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
: bookedAt;
|
||||
const { jy, jm, jd } = dateToJalali(when);
|
||||
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
|
||||
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
|
||||
const latest = await tx.financialDocument.findFirst({
|
||||
where: { organizationId, reference: { startsWith: prefix } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const current = Number(latest?.reference?.slice(prefix.length));
|
||||
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
|
||||
return `${prefix}${String(next).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
|
||||
const term = q?.trim() ?? '';
|
||||
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
|
||||
const [people, companies, users] = await Promise.all([
|
||||
this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}),
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.company.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(contains ? { name: contains } : {}),
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.user.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
isActive: true,
|
||||
...(contains ? { fullName: contains } : {}),
|
||||
},
|
||||
select: { id: true, fullName: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
people: people.map((row) => ({
|
||||
id: row.id,
|
||||
name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری',
|
||||
})),
|
||||
companies,
|
||||
users: users.map((row) => ({ id: row.id, name: row.fullName })),
|
||||
};
|
||||
}
|
||||
|
||||
async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) {
|
||||
const firstName = input.firstName?.trim() ?? '';
|
||||
const lastName = input.lastName?.trim() ?? '';
|
||||
if (`${firstName} ${lastName}`.trim().length < 2) {
|
||||
throw new BadRequestException('نام شخص را وارد کنید.');
|
||||
}
|
||||
const person = await this.people.create(actor, { firstName, lastName });
|
||||
return { id: person.id, name: person.fullName, kind: 'person' as const };
|
||||
}
|
||||
|
||||
async createPartyCompany(actor: Actor, input: { name?: string }) {
|
||||
const name = input.name?.trim() ?? '';
|
||||
if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.');
|
||||
const company = await this.companies.create(actor, { name });
|
||||
return { id: company.id, name: company.name, kind: 'company' as const };
|
||||
}
|
||||
|
||||
async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise<TreasuryPartyBalanceDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
voidedAt: null,
|
||||
status: { in: ['open', 'partial'] },
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
},
|
||||
select: {
|
||||
amount: true,
|
||||
partyKind: true,
|
||||
personId: true,
|
||||
companyId: true,
|
||||
userId: true,
|
||||
partyName: true,
|
||||
movements: { select: { amount: true, voidedAt: true } },
|
||||
},
|
||||
});
|
||||
const buckets = new Map<string, {
|
||||
key: string;
|
||||
partyKind: FinancialPartyKind | null;
|
||||
partyName: string;
|
||||
amount: Prisma.Decimal;
|
||||
settled: Prisma.Decimal;
|
||||
remaining: Prisma.Decimal;
|
||||
}>();
|
||||
for (const row of rows) {
|
||||
if (row.partyKind === 'other') continue;
|
||||
const settled = this.settledOf(row.movements);
|
||||
const rawRemaining = row.amount.minus(settled);
|
||||
if (!rawRemaining.greaterThan(0)) continue;
|
||||
const linkedId = row.personId || row.companyId || row.userId;
|
||||
const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user'
|
||||
? row.partyKind
|
||||
: null;
|
||||
const key = partyKind && linkedId
|
||||
? `${partyKind}:${linkedId}`
|
||||
: `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`;
|
||||
const current = buckets.get(key) ?? {
|
||||
key,
|
||||
partyKind,
|
||||
partyName: row.partyName?.trim() || 'بدون طرف حساب',
|
||||
amount: new Prisma.Decimal(0),
|
||||
settled: new Prisma.Decimal(0),
|
||||
remaining: new Prisma.Decimal(0),
|
||||
};
|
||||
current.amount = current.amount.plus(row.amount);
|
||||
current.settled = current.settled.plus(settled);
|
||||
current.remaining = current.remaining.plus(rawRemaining);
|
||||
buckets.set(key, current);
|
||||
}
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => b.remaining.comparedTo(a.remaining))
|
||||
.map((row) => ({
|
||||
key: row.key,
|
||||
partyKind: row.partyKind,
|
||||
partyName: row.partyName,
|
||||
amount: money(row.amount),
|
||||
settledAmount: money(row.settled),
|
||||
remaining: money(row.remaining),
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveParty(
|
||||
actor: Actor,
|
||||
input: {
|
||||
partyKind?: FinancialPartyKind | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
},
|
||||
) {
|
||||
const kind = input.partyKind;
|
||||
if (kind === 'other') {
|
||||
return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' };
|
||||
}
|
||||
if (kind === 'person') {
|
||||
const id = input.personId?.trim();
|
||||
if (!id) throw new BadRequestException('مشتری را انتخاب کنید.');
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('مشتری پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'person' as const,
|
||||
personId: person.id,
|
||||
companyId: null,
|
||||
userId: null,
|
||||
partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری',
|
||||
};
|
||||
}
|
||||
if (kind === 'company') {
|
||||
const id = input.companyId?.trim();
|
||||
if (!id) throw new BadRequestException('شرکت را انتخاب کنید.');
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'company' as const,
|
||||
personId: null,
|
||||
companyId: company.id,
|
||||
userId: null,
|
||||
partyName: company.name,
|
||||
};
|
||||
}
|
||||
if (kind === 'user') {
|
||||
const id = input.userId?.trim();
|
||||
if (!id) throw new BadRequestException('کاربر را انتخاب کنید.');
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
select: { id: true, fullName: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException('کاربر پیدا نشد.');
|
||||
return {
|
||||
partyKind: 'user' as const,
|
||||
personId: null,
|
||||
companyId: null,
|
||||
userId: user.id,
|
||||
partyName: user.fullName,
|
||||
};
|
||||
}
|
||||
throw new BadRequestException('طرف حساب را انتخاب کنید.');
|
||||
}
|
||||
|
||||
async settleDocument(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
@@ -948,7 +1237,10 @@ export class TreasuryService {
|
||||
bookedAt: Date;
|
||||
dueAt: Date | null;
|
||||
categoryId: string | null;
|
||||
partyKind?: string | null;
|
||||
personId: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
method: string | null;
|
||||
@@ -975,7 +1267,10 @@ export class TreasuryService {
|
||||
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
||||
categoryId: row.categoryId,
|
||||
categoryName: row.category?.name ?? null,
|
||||
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
|
||||
personId: row.personId,
|
||||
companyId: row.companyId ?? null,
|
||||
userId: row.userId ?? null,
|
||||
partyName: row.partyName,
|
||||
accountId: row.accountId,
|
||||
accountName: row.account?.name ?? null,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>منتکت | Montact CRM</title>
|
||||
<link rel="icon" type="image/png" href="/brand/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/logo.svg" />
|
||||
<style>
|
||||
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیقتر میکند */
|
||||
.app-frame {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
|
||||
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
|
||||
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
|
||||
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
|
||||
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
|
||||
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
|
||||
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -3,10 +3,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
|
||||
import type { OrganizationMembershipDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -28,12 +26,8 @@ const auth = useAuthStore();
|
||||
|
||||
const orgs = ref<OrganizationMembershipDto[]>([]);
|
||||
const open = ref(false);
|
||||
const createOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const error = ref('');
|
||||
const newName = ref('');
|
||||
const newField = ref<BusinessField>('personal');
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const menuEl = ref<HTMLElement | null>(null);
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
@@ -60,7 +54,7 @@ const currentInitial = computed(() => {
|
||||
|
||||
const canSwitch = computed(() => orgs.value.length > 1);
|
||||
const isWorkspace = computed(() => props.variant === 'workspace');
|
||||
/** در حالت workspace منو همیشه باز میشود (لیست org + محیط جدید)؛ در حالت پیشفرض فقط وقتی بیش از یکی باشد */
|
||||
/** در حالت workspace منو همیشه باز میشود؛ در حالت پیشفرض فقط وقتی بیش از یکی باشد */
|
||||
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
@@ -148,34 +142,6 @@ async function choose(org: OrganizationMembershipDto) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
closeMenu();
|
||||
newName.value = '';
|
||||
newField.value = 'personal';
|
||||
error.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام محیط باید حداقل دو حرف باشد.';
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.createOrganization({
|
||||
name,
|
||||
businessField: newField.value,
|
||||
});
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ساخت محیط ناموفق بود');
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick(event: MouseEvent) {
|
||||
if (!open.value || Date.now() < ignoreDocClickUntil) return;
|
||||
const target = event.target;
|
||||
@@ -260,36 +226,9 @@ onUnmounted(() => {
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" class="create" :disabled="loading" @click="openCreate">
|
||||
محیط جدید
|
||||
</button>
|
||||
<p v-if="error && !createOpen" class="err">{{ error }}</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
|
||||
<form class="form" @submit.prevent="submitCreate">
|
||||
<label>
|
||||
نام محیط
|
||||
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
|
||||
</label>
|
||||
<label>
|
||||
زمینه فعالیت
|
||||
<select v-model="newField">
|
||||
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
||||
{{ BUSINESS_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
|
||||
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
|
||||
</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -585,61 +524,9 @@ onUnmounted(() => {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.create {
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.7rem 0.65rem 0.55rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
border-radius: 0 0 12px 12px;
|
||||
color: var(--accent-deep);
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.menu.org-ws .create:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.ghost,
|
||||
.primary {
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -264,10 +264,12 @@ defineExpose({ refresh: load });
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -279,8 +281,9 @@ defineExpose({ refresh: load });
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, 100%);
|
||||
max-height: min(90vh, 640px);
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SearchOutlined, SendOutlined } from '@/icons/ant-bridge';
|
||||
import { SendOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Input as AInput,
|
||||
@@ -57,17 +57,15 @@ const statusMessage = computed(() => {
|
||||
<div class="home-hero__row">
|
||||
<div class="home-hero__greet">
|
||||
<div class="home-hero__identity" :aria-label="firstName">
|
||||
<div class="home-hero__frame">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="home-hero__copy">
|
||||
@@ -82,9 +80,6 @@ const statusMessage = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="home-hero__ask">
|
||||
<span class="home-hero__ask-ico" aria-hidden="true">
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<a-input
|
||||
id="home-hero-ask"
|
||||
:value="askQuery"
|
||||
@@ -99,13 +94,16 @@ const statusMessage = computed(() => {
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="round"
|
||||
class="home-hero__ask-btn"
|
||||
:disabled="asking"
|
||||
:loading="asking"
|
||||
aria-label="ارسال"
|
||||
@click="emit('ask')"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
<span class="home-hero__ask-label">بپرس</span>
|
||||
<span class="home-hero__ask-btn-inner">
|
||||
<SendOutlined />
|
||||
<span class="home-hero__ask-label">بپرس</span>
|
||||
</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +141,7 @@ const statusMessage = computed(() => {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgb(110 231 183 / 0.5);
|
||||
border: 1px solid rgb(16 137 125 / 0.28);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
@@ -163,10 +161,10 @@ const statusMessage = computed(() => {
|
||||
background:
|
||||
linear-gradient(
|
||||
115deg,
|
||||
#ecfdf5 0%,
|
||||
#d1fae5 28%,
|
||||
#ccfbf1 55%,
|
||||
#f0fdfa 78%,
|
||||
#eef8f7 0%,
|
||||
#d0ebe8 28%,
|
||||
#c5e6e2 55%,
|
||||
#eef8f7 78%,
|
||||
#ffffff 100%
|
||||
);
|
||||
}
|
||||
@@ -174,14 +172,14 @@ const statusMessage = computed(() => {
|
||||
.home-hero__pattern {
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.28) 1px, transparent 0),
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 137, 125, 0.28) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 184, 166, 0.08) 25%,
|
||||
rgba(16, 137, 125, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(20, 184, 166, 0.08) 50%,
|
||||
rgba(20, 184, 166, 0.08) 75%,
|
||||
rgba(16, 137, 125, 0.08) 50%,
|
||||
rgba(16, 137, 125, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
@@ -201,7 +199,7 @@ const statusMessage = computed(() => {
|
||||
top: -6rem;
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
background: rgb(52 211 153 / 0.2);
|
||||
background: rgb(22 193 168 / 0.22);
|
||||
}
|
||||
|
||||
.home-hero__orb--end {
|
||||
@@ -209,7 +207,7 @@ const statusMessage = computed(() => {
|
||||
bottom: -5rem;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
background: rgb(20 184 166 / 0.15);
|
||||
background: rgb(16 137 125 / 0.16);
|
||||
}
|
||||
|
||||
.home-hero__row {
|
||||
@@ -237,19 +235,8 @@ const statusMessage = computed(() => {
|
||||
.home-hero__identity {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.home-hero__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(145deg, #34d399 0%, #0d9488 48%, #059669 100%);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 8px 18px -10px rgba(4, 120, 87, 0.55);
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.home-hero__photo,
|
||||
@@ -257,33 +244,30 @@ const statusMessage = computed(() => {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(1rem - 2px);
|
||||
border-radius: 0.85rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.home-hero__monogram {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #ecfdf5;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
|
||||
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
|
||||
color: #eef8f7;
|
||||
background: linear-gradient(160deg, #16c1a8 0%, #10897d 100%);
|
||||
letter-spacing: -0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.home-hero__online {
|
||||
position: absolute;
|
||||
inset-inline-start: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
inset-inline-start: 0;
|
||||
bottom: 0;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #10b981;
|
||||
background: #10897d;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.home-hero__copy {
|
||||
@@ -340,22 +324,10 @@ const statusMessage = computed(() => {
|
||||
}
|
||||
|
||||
.home-hero__ask:focus-within {
|
||||
border-color: #6ee7b7;
|
||||
border-color: #16c1a8;
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(167 243 208 / 0.7),
|
||||
0 4px 12px rgb(6 78 59 / 0.05);
|
||||
}
|
||||
|
||||
.home-hero__ask-ico {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
0 0 0 2px rgb(16 137 125 / 0.18),
|
||||
0 4px 12px rgb(10 92 84 / 0.06);
|
||||
}
|
||||
|
||||
.home-hero__ask-input {
|
||||
@@ -377,6 +349,20 @@ const statusMessage = computed(() => {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.home-hero__ask-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-hero__ask-btn-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-hero__ask-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -76,11 +76,13 @@ const priorityFlag = computed(() =>
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||
opacity: props.dragging ? 0.45 : 1,
|
||||
opacity: props.dragging ? 0.4 : 1,
|
||||
transform: props.dragging ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: token.value.boxShadowTertiary,
|
||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import AddDealButton from './AddDealButton.vue';
|
||||
import DealCard from './DealCard.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -51,6 +51,8 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const bodyEl = ref<HTMLElement | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const renaming = ref(false);
|
||||
const draft = ref('');
|
||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
||||
function trackDrag(event: DragEvent, index: number | null) {
|
||||
event.preventDefault();
|
||||
dragScrollColumn(bodyEl.value, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
|
||||
event.stopPropagation();
|
||||
trackDrag(event, pointerDropIndex(index, event));
|
||||
emit('dragOver', stageId, event);
|
||||
}
|
||||
|
||||
function onColumnDrop(event: DragEvent) {
|
||||
const at = insertAt.value ?? undefined;
|
||||
clearDragUi();
|
||||
emit('drop', props.stage.id, event, at);
|
||||
}
|
||||
|
||||
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit('drop', stageId, event, pointerDropIndex(index, event));
|
||||
const at = insertAt.value ?? pointerDropIndex(index, event);
|
||||
clearDragUi();
|
||||
emit('drop', stageId, event, at);
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<template>
|
||||
<section
|
||||
:style="columnStyle"
|
||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="emit('drop', stage.id, $event)"
|
||||
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="onColumnDrop"
|
||||
>
|
||||
<div :style="headStyle">
|
||||
<span
|
||||
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div
|
||||
v-for="(deal, index) in stage.deals"
|
||||
:key="deal.id"
|
||||
@dragover="onCardDragOver(stage.id, $event)"
|
||||
@dragover="onCardDragOver(stage.id, index, $event)"
|
||||
@drop="onCardDrop(stage.id, index, $event)"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === index)"
|
||||
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
|
||||
/>
|
||||
<DealCard
|
||||
:deal="deal"
|
||||
:accent="stage.color"
|
||||
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
@menu="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
|
||||
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="!stage.deals.length"
|
||||
type="secondary"
|
||||
|
||||
@@ -23,7 +23,7 @@ const links = computed(() => {
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها', section: 'treasury_categories' },
|
||||
{ to: '/finance/categories', label: 'دستهبندی', section: 'treasury_categories' },
|
||||
];
|
||||
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Table as ATable } from 'ant-design-vue';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: TreasuryPartyBalanceDto[];
|
||||
settledLabel: string;
|
||||
}>();
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
|
||||
{
|
||||
title: 'نوع',
|
||||
key: 'partyKind',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
|
||||
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
|
||||
},
|
||||
{
|
||||
title: 'مبلغ کل',
|
||||
key: 'amount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
|
||||
},
|
||||
{
|
||||
title: props.settledLabel,
|
||||
key: 'settledAmount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'remaining',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
|
||||
const VNodes = defineComponent({
|
||||
props: { vnodes: { type: Object, required: true } },
|
||||
render() {
|
||||
return this.vnodes;
|
||||
},
|
||||
});
|
||||
|
||||
const ASelectOption = ASelect.Option;
|
||||
const ASelectOptGroup = ASelect.OptGroup;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||
const partyId = defineModel<string | undefined>('partyId');
|
||||
|
||||
type Choice = { id: string; name: string };
|
||||
|
||||
const people = ref<Choice[]>([]);
|
||||
const companies = ref<Choice[]>([]);
|
||||
const users = ref<Choice[]>([]);
|
||||
const labels = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const selectOpen = ref(false);
|
||||
const personOpen = ref(false);
|
||||
const companyOpen = ref(false);
|
||||
|
||||
const selectValue = computed(() => {
|
||||
if (kind.value === 'other') return 'other';
|
||||
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
|
||||
if (kind.value !== group || !partyId.value) return list;
|
||||
if (list.some((row) => row.id === partyId.value)) return list;
|
||||
const name = labels.value[`${group}:${partyId.value}`] || 'انتخابشده';
|
||||
return [{ id: partyId.value, name }, ...list];
|
||||
}
|
||||
|
||||
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
|
||||
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
|
||||
const userOptions = computed(() => withCurrent(users.value, 'user'));
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ثبت ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق بود.';
|
||||
}
|
||||
|
||||
let timer = 0;
|
||||
function onSearch(value: string) {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void load(value);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function load(q = '') {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
|
||||
params: { q: q.trim() || undefined },
|
||||
});
|
||||
people.value = data.people;
|
||||
companies.value = data.companies;
|
||||
users.value = data.users;
|
||||
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
|
||||
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
|
||||
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
|
||||
} catch (e) {
|
||||
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(value: unknown) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
kind.value = undefined;
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (value === 'other') {
|
||||
kind.value = 'other';
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
const splitAt = value.indexOf(':');
|
||||
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
|
||||
const id = value.slice(splitAt + 1);
|
||||
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
|
||||
kind.value = nextKind;
|
||||
partyId.value = id;
|
||||
}
|
||||
|
||||
function onDropdown(open: boolean) {
|
||||
selectOpen.value = open;
|
||||
}
|
||||
|
||||
function openPersonForm() {
|
||||
selectOpen.value = false;
|
||||
personOpen.value = true;
|
||||
}
|
||||
|
||||
function openCompanyForm() {
|
||||
selectOpen.value = false;
|
||||
companyOpen.value = true;
|
||||
}
|
||||
|
||||
function onPersonCreated(person?: PersonDto) {
|
||||
personOpen.value = false;
|
||||
if (!person?.id) return;
|
||||
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
|
||||
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
|
||||
labels.value[`person:${person.id}`] = name;
|
||||
kind.value = 'person';
|
||||
partyId.value = person.id;
|
||||
}
|
||||
|
||||
function onCompanyCreated(company?: CompanyDto) {
|
||||
companyOpen.value = false;
|
||||
if (!company?.id) return;
|
||||
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
|
||||
labels.value[`company:${company.id}`] = company.name;
|
||||
kind.value = 'company';
|
||||
partyId.value = company.id;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-select
|
||||
:open="selectOpen"
|
||||
show-search
|
||||
@dropdownVisibleChange="onDropdown"
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:loading="loading"
|
||||
:value="selectValue"
|
||||
placeholder="مشتری، شرکت، کاربر یا سایر"
|
||||
style="width: 100%"
|
||||
@search="onSearch"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-select-option value="other" label="سایر">سایر</a-select-option>
|
||||
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||
<a-select-option
|
||||
v-for="row in peopleOptions"
|
||||
:key="`person:${row.id}`"
|
||||
:value="`person:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="companyOptions.length" label="شرکتها">
|
||||
<a-select-option
|
||||
v-for="row in companyOptions"
|
||||
:key="`company:${row.id}`"
|
||||
:value="`company:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="userOptions.length" label="کاربران">
|
||||
<a-select-option
|
||||
v-for="row in userOptions"
|
||||
:key="`user:${row.id}`"
|
||||
:value="`user:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<template #dropdownRender="{ menuNode }">
|
||||
<v-nodes :vnodes="menuNode" />
|
||||
<a-flex @mousedown.prevent>
|
||||
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
|
||||
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-select>
|
||||
<a-typography-text v-if="kind === 'other'" type="secondary">
|
||||
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمیماند.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
|
||||
<PersonFormView
|
||||
v-if="personOpen"
|
||||
embedded
|
||||
@saved="onPersonCreated"
|
||||
@cancel="personOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
|
||||
<CompanyFormView
|
||||
v-if="companyOpen"
|
||||
embedded
|
||||
@saved="onCompanyCreated"
|
||||
@cancel="companyOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
|
||||
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی میکند تا کاربر خودش آن را عوض کند. */
|
||||
export function useSettleDefault(
|
||||
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
|
||||
) {
|
||||
const dirty = ref(false);
|
||||
let mirroring = false;
|
||||
|
||||
function locked() {
|
||||
return form.value.partyKind === 'other';
|
||||
}
|
||||
|
||||
function copy() {
|
||||
mirroring = true;
|
||||
form.value.settleAmount = form.value.amount;
|
||||
mirroring = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value.amount,
|
||||
() => {
|
||||
if (locked() || !dirty.value) copy();
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.settleAmount,
|
||||
() => {
|
||||
if (!mirroring && !locked()) dirty.value = true;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.partyKind,
|
||||
(kind) => {
|
||||
if (kind === 'other') {
|
||||
dirty.value = false;
|
||||
copy();
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
return {
|
||||
reset() {
|
||||
dirty.value = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime, formatJalaliShort, toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import QuickCreate from '@/components/QuickCreate.vue';
|
||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||
@@ -13,13 +14,15 @@ import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
||||
import SmsHeaderWallet from '@/components/SmsHeaderWallet.vue';
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
ActivitySummaryDto,
|
||||
AuthUserDto,
|
||||
NotificationDto,
|
||||
NotificationsListDto,
|
||||
import {
|
||||
brand,
|
||||
type ActivitySummaryDto,
|
||||
type AuthUserDto,
|
||||
type NotificationDto,
|
||||
type NotificationsListDto,
|
||||
type OrgUserDto,
|
||||
} from '@mento/shared';
|
||||
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { USER_ROLE_LABELS, canManageSettings, canViewAllRecords } from '@/lib/roles';
|
||||
import { hasSectionPermission, sectionPermissionForPath } from '@/lib/section-permissions';
|
||||
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
|
||||
|
||||
@@ -38,8 +41,12 @@ const canSettings = computed(() => canManageSettings(auth.user?.role));
|
||||
const showSettingsNav = computed(
|
||||
() => canSettings.value || canOpenTeamWhileViewing.value,
|
||||
);
|
||||
/** فقط وقتی بهجای نقش دیگری وارد شدهاید؛ در پنل/دسترسی خود کاربر نشان داده نمیشود */
|
||||
const showOwnRecordsBanner = computed(
|
||||
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
|
||||
() =>
|
||||
Boolean(auth.user?.viewAs) &&
|
||||
Boolean(auth.user?.role) &&
|
||||
!canViewAllRecords(auth.user?.role),
|
||||
);
|
||||
const todayLabel = computed(() => formatJalaliShort(new Date()));
|
||||
|
||||
@@ -260,12 +267,79 @@ const hubCanBack = computed(() => hubStack.value.length > 0);
|
||||
|
||||
const profileFullName = computed(() => auth.user?.fullName?.trim() || '');
|
||||
const profileDisplayName = computed(() => profileFullName.value);
|
||||
const profileSecondary = computed(
|
||||
() => auth.user?.email?.trim() || auth.user?.mobile?.trim() || '',
|
||||
const profileSecondary = computed(() => {
|
||||
const mobile = auth.user?.mobile?.trim();
|
||||
if (mobile) return mobile;
|
||||
const email = auth.user?.email?.trim() || '';
|
||||
const fromSynthetic = email.match(/^(\d+)@users\.mento\.local$/i);
|
||||
return fromSynthetic?.[1] || '';
|
||||
});
|
||||
|
||||
const roleUsers = ref<OrgUserDto[]>([]);
|
||||
const roleBusy = ref(false);
|
||||
const roleError = ref('');
|
||||
|
||||
/** سوئیچ نقش فقط برای مدیر/مالک همین محیط (یا وقتی مالک در حال مشاهده است) */
|
||||
const canSwitchRoles = computed(
|
||||
() => canManageSettings(auth.user?.role) || canOpenTeamWhileViewing.value,
|
||||
);
|
||||
|
||||
function roleUserLabel(user: OrgUserDto) {
|
||||
return user.orgRoleName || USER_ROLE_LABELS[user.role] || user.role;
|
||||
}
|
||||
|
||||
const currentRoleLabel = computed(() => {
|
||||
const current = roleUsers.value.find((user) => user.id === auth.user?.id);
|
||||
if (current) return roleUserLabel(current);
|
||||
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
|
||||
return profileSecondary.value;
|
||||
});
|
||||
|
||||
const sortedRoleUsers = computed(() => {
|
||||
if (!canSwitchRoles.value) return [];
|
||||
const me = auth.user?.id;
|
||||
return [...roleUsers.value].sort((a, b) => {
|
||||
if (a.id === me) return -1;
|
||||
if (b.id === me) return 1;
|
||||
return roleUserLabel(a).localeCompare(roleUserLabel(b), 'fa');
|
||||
});
|
||||
});
|
||||
|
||||
async function loadRoleUsers() {
|
||||
if (!canSwitchRoles.value) {
|
||||
roleUsers.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||||
// فقط کاربران فعال همین محیط (اسکوپ توکن)
|
||||
roleUsers.value = data.filter((user) => user.isActive !== false);
|
||||
} catch {
|
||||
roleUsers.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function switchRoleUser(user: OrgUserDto) {
|
||||
if (!canSwitchRoles.value || roleBusy.value) return;
|
||||
if (user.id === auth.user?.id) {
|
||||
closeUserMenu();
|
||||
return;
|
||||
}
|
||||
roleBusy.value = true;
|
||||
roleError.value = '';
|
||||
try {
|
||||
await auth.viewAs(user.id);
|
||||
closeUserMenu();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
roleError.value = 'تغییر نقش ناموفق بود.';
|
||||
roleBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserMenu() {
|
||||
userMenuOpen.value = false;
|
||||
roleError.value = '';
|
||||
}
|
||||
|
||||
function closeOrgSwitcher() {
|
||||
@@ -289,6 +363,9 @@ async function toggleUserMenu() {
|
||||
userMenuOpen.value = true;
|
||||
await nextTick();
|
||||
placeUserMenu();
|
||||
await loadRoleUsers();
|
||||
await nextTick();
|
||||
placeUserMenu();
|
||||
}
|
||||
|
||||
function onOrgMenuOpen() {
|
||||
@@ -553,6 +630,41 @@ function formatTime(iso: string) {
|
||||
return formatJalaliDateTime(d);
|
||||
}
|
||||
|
||||
type NotifTone = 'overdue' | 'reminder' | 'assigned' | 'moved' | 'neutral';
|
||||
|
||||
function notifTone(type: string): NotifTone {
|
||||
if (type === 'activity_overdue' || type === 'routine_task_overdue') return 'overdue';
|
||||
if (
|
||||
type === 'activity_reminder' ||
|
||||
type === 'note_reminder' ||
|
||||
type === 'routine_task_reminder' ||
|
||||
type === 'cheque_due'
|
||||
) {
|
||||
return 'reminder';
|
||||
}
|
||||
if (type === 'deal_moved') return 'moved';
|
||||
if (
|
||||
type === 'activity_assigned' ||
|
||||
type === 'ticket_assigned' ||
|
||||
type === 'routine_task_assigned' ||
|
||||
type === 'automation'
|
||||
) {
|
||||
return 'assigned';
|
||||
}
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function notifIcon(type: string) {
|
||||
if (type === 'activity_overdue' || type === 'routine_task_overdue') return 'warning' as const;
|
||||
if (type === 'cheque_due') return 'dollar' as const;
|
||||
if (type.includes('reminder')) return 'clock' as const;
|
||||
if (type === 'deal_moved') return 'trend' as const;
|
||||
if (type === 'automation') return 'sparkle' as const;
|
||||
if (type === 'ticket_assigned') return 'mail' as const;
|
||||
if (type.endsWith('_assigned')) return 'check' as const;
|
||||
return 'bell' as const;
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
const q = globalSearch.value.trim();
|
||||
if (!q) return;
|
||||
@@ -621,6 +733,9 @@ onUnmounted(() => {
|
||||
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
|
||||
|
||||
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
|
||||
<div v-if="!collapsed" class="sidebar-logo">
|
||||
<img :src="brand.logoWordmark" :alt="brand.nameFa" width="118" height="22" />
|
||||
</div>
|
||||
<div class="brand-row">
|
||||
<div class="brand-cluster">
|
||||
<OrgSwitcher
|
||||
@@ -916,7 +1031,7 @@ onUnmounted(() => {
|
||||
:style="userMenuStyle"
|
||||
role="menu"
|
||||
>
|
||||
<div class="user-popover-head">
|
||||
<div class="user-popover-head divided">
|
||||
<span class="avatar-wrap head-avatar">
|
||||
<span class="avatar">
|
||||
<img
|
||||
@@ -929,9 +1044,55 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<span class="user-popover-meta">
|
||||
<strong class="user-popover-name">{{ profileDisplayName }}</strong>
|
||||
<small class="user-popover-sub">{{ planLabel || profileSecondary }}</small>
|
||||
<small class="user-popover-sub">{{ currentRoleLabel || profileSecondary }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="sortedRoleUsers.length > 1"
|
||||
class="user-role-list"
|
||||
:class="{ collapsible: sortedRoleUsers.length > 3 }"
|
||||
role="group"
|
||||
aria-label="نقشها"
|
||||
>
|
||||
<p class="user-role-hint">نقشها</p>
|
||||
<div class="user-role-scroll">
|
||||
<button
|
||||
v-for="user in sortedRoleUsers"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="user-role-item"
|
||||
:class="{ on: user.id === auth.user?.id }"
|
||||
:disabled="roleBusy"
|
||||
role="menuitem"
|
||||
:aria-current="user.id === auth.user?.id ? 'true' : undefined"
|
||||
@click.stop="switchRoleUser(user)"
|
||||
>
|
||||
<UserAvatar
|
||||
class="user-role-avatar"
|
||||
:name="user.fullName"
|
||||
:user-id="user.id"
|
||||
:has-avatar="user.hasAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<span class="user-role-copy">
|
||||
<strong>{{ roleUserLabel(user) }}</strong>
|
||||
<small>{{ user.fullName }}</small>
|
||||
</span>
|
||||
<UiIcon
|
||||
v-if="user.id === auth.user?.id"
|
||||
class="user-role-check"
|
||||
name="check"
|
||||
:size="15"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="roleError" class="user-role-err">{{ roleError }}</p>
|
||||
<div
|
||||
v-if="sortedRoleUsers.length > 1 || roleError"
|
||||
class="user-popover-sep"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<RouterLink
|
||||
class="user-popover-item"
|
||||
:to="{ name: 'profile' }"
|
||||
@@ -1058,7 +1219,10 @@ onUnmounted(() => {
|
||||
<div class="notif-backdrop" @click="closeNotif" />
|
||||
<div class="notif-panel" role="dialog" aria-label="اعلانها" :style="notifPos ?? undefined">
|
||||
<header class="notif-head">
|
||||
<strong>اعلانها</strong>
|
||||
<div class="notif-head-copy">
|
||||
<strong>اعلانها</strong>
|
||||
<span v-if="hasUnread" class="notif-count">{{ toFaDigits(unreadCount) }} خواندهنشده</span>
|
||||
</div>
|
||||
<div class="notif-head-actions">
|
||||
<button v-if="hasUnread" class="linkish" type="button" @click="markAll">
|
||||
همه خوانده شد
|
||||
@@ -1068,17 +1232,26 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
|
||||
<p v-if="loading && !items.length" class="notif-empty">در حال بارگذاری…</p>
|
||||
<ul v-else-if="items.length" class="notif-list">
|
||||
<li v-for="item in items" :key="item.id" :class="{ unread: !item.readAt }">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:class="['tone-' + notifTone(item.type), { unread: !item.readAt }]"
|
||||
>
|
||||
<button type="button" @click="markRead(item)">
|
||||
<span class="title">{{ item.title }}</span>
|
||||
<span v-if="item.body" class="body">{{ item.body }}</span>
|
||||
<span class="time">{{ formatTime(item.createdAt) }}</span>
|
||||
<span class="notif-ico" aria-hidden="true">
|
||||
<UiIcon :name="notifIcon(item.type)" :size="18" />
|
||||
</span>
|
||||
<span class="notif-copy">
|
||||
<span class="notif-title">{{ item.title }}</span>
|
||||
<span v-if="item.body" class="notif-body">{{ item.body }}</span>
|
||||
<span class="notif-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">اعلانی نیست.</p>
|
||||
<p v-else class="notif-empty">اعلانی نیست.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1398,6 +1571,24 @@ onUnmounted(() => {
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.2rem 0.5rem 0.55rem;
|
||||
margin-bottom: 0.2rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1454,12 +1645,13 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.58rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
@@ -1468,6 +1660,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.nav-item.router-link-active {
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
color: #0f172a;
|
||||
box-shadow: none;
|
||||
@@ -1476,6 +1669,7 @@ onUnmounted(() => {
|
||||
.nav-item.router-link-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset-inline-start: 0.2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
@@ -1485,6 +1679,36 @@ onUnmounted(() => {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item.router-link-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
border-radius: inherit;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 137, 125, 0.28) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 137, 125, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(16, 137, 125, 0.08) 50%,
|
||||
rgba(16, 137, 125, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size:
|
||||
18px 18px,
|
||||
28px 28px;
|
||||
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.nav-item.router-link-active > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-item.router-link-active :deep(.ico) {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
@@ -1495,7 +1719,8 @@ onUnmounted(() => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shell.hub-open .nav-item.router-link-active:not(.hub-on)::before {
|
||||
.shell.hub-open .nav-item.router-link-active:not(.hub-on)::before,
|
||||
.shell.hub-open .nav-item.router-link-active:not(.hub-on)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1738,7 +1963,7 @@ onUnmounted(() => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-chev {
|
||||
@@ -1785,7 +2010,11 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.4rem 0.5rem 0.55rem;
|
||||
padding: 0.4rem 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
.user-popover-head.divided {
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
@@ -1823,6 +2052,126 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role-list {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
padding: 0.2rem 0.1rem 0.1rem;
|
||||
}
|
||||
|
||||
.user-role-hint {
|
||||
margin: 0;
|
||||
padding: 0.2rem 0.55rem 0.15rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.user-role-scroll {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.user-role-list.collapsible .user-role-scroll {
|
||||
/* دقیقاً جای ۳ ردیف؛ بقیه در همین فضا اسکرول میخورند */
|
||||
max-height: calc(3 * (28px + 0.9rem) + 2 * 0.15rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.user-role-list.collapsible .user-role-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.user-role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-height: calc(28px + 0.9rem);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
padding: 0.45rem 0.5rem;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-role-item:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.user-role-item.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.user-role-item:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.user-role-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.user-role-avatar {
|
||||
flex-shrink: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.user-role-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.user-role-copy strong {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role-copy small {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-role-check {
|
||||
color: #0f766e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-role-err {
|
||||
margin: 0;
|
||||
padding: 0.15rem 0.55rem 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.user-plan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2163,7 +2512,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item.router-link-active {
|
||||
background: #f3f4f6;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
@@ -2498,13 +2846,14 @@ onUnmounted(() => {
|
||||
.notif-panel {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(360px, calc(100vw - 1rem));
|
||||
max-height: min(70vh, 480px);
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -2513,59 +2862,140 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.55rem;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-bottom: 1px solid #e8eef3;
|
||||
}
|
||||
|
||||
.notif-head-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-head-copy strong {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.notif-count {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-subtle);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #14b8a6;
|
||||
color: var(--accent-deep);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
margin: 0;
|
||||
padding: 1.4rem 0.9rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
padding: 0.45rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.notif-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.notif-list button {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f6;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 0.7rem;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.notif-list li.unread button {
|
||||
background: #ecfdf8;
|
||||
.notif-ico {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
.notif-copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.body,
|
||||
.time,
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
.notif-body {
|
||||
color: var(--ink-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.notif-list li.tone-overdue .notif-ico {
|
||||
color: var(--danger);
|
||||
}
|
||||
.notif-list li.tone-reminder .notif-ico {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
.notif-list li.tone-assigned .notif-ico {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.notif-list li.tone-moved .notif-ico {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.notif-list li:not(.unread) .notif-body,
|
||||
.notif-list li:not(.unread) .notif-time {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -431,6 +431,43 @@ export function pointerDropIndex(index: number, event: DragEvent): number {
|
||||
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
const DRAG_SCROLL_EDGE = 72;
|
||||
const DRAG_SCROLL_MAX = 16;
|
||||
let dragScrollEl: HTMLElement | null = null;
|
||||
let dragScrollY = 0;
|
||||
let dragScrollRaf = 0;
|
||||
|
||||
function dragScrollTick() {
|
||||
dragScrollRaf = 0;
|
||||
const el = dragScrollEl;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let delta = 0;
|
||||
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
|
||||
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
|
||||
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
|
||||
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
|
||||
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||
}
|
||||
if (!delta || el.scrollHeight <= el.clientHeight) return;
|
||||
el.scrollTop += delta;
|
||||
dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||
}
|
||||
|
||||
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت میکند تا مقصد دیده شود. */
|
||||
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
|
||||
dragScrollEl = el;
|
||||
dragScrollY = clientY;
|
||||
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||
}
|
||||
|
||||
export function stopDragScroll() {
|
||||
dragScrollEl = null;
|
||||
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
|
||||
dragScrollRaf = 0;
|
||||
}
|
||||
|
||||
export function moveStageToIndex(
|
||||
stages: PipelineStage[],
|
||||
stageId: string,
|
||||
|
||||
@@ -369,8 +369,8 @@ export const router = createRouter({
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'دستهبندیهای مالی',
|
||||
treasuryDescription: 'دستههای پیشفرض با اولین بازدید ساخته میشوند.',
|
||||
treasuryTitle: 'دستهبندی',
|
||||
treasuryDescription: 'دستههای درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
--line-light: #f1f5f9;
|
||||
--line-strong: #cbd5e1;
|
||||
|
||||
/* Accent / Primary (Teal) */
|
||||
--accent: #14b8a6;
|
||||
--accent-hover: #0d9488;
|
||||
--accent-deep: #0f766e;
|
||||
--accent-soft: #ccfbf1;
|
||||
--accent-subtle: #f0fdfa;
|
||||
/* Accent / Primary (Montact brand) */
|
||||
--accent: #10897d;
|
||||
--accent-hover: #0d7369;
|
||||
--accent-deep: #0a5c54;
|
||||
--accent-soft: #d0ebe8;
|
||||
--accent-subtle: #eef8f7;
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
/* Semantics */
|
||||
|
||||
@@ -14,7 +14,7 @@ const GRAY_200 = '#e5e7eb';
|
||||
|
||||
export const antdTheme = {
|
||||
token: {
|
||||
colorPrimary: '#10b981',
|
||||
colorPrimary: '#10897d',
|
||||
fontFamily: '"IRANSans", Tahoma, sans-serif',
|
||||
|
||||
/** Page / subtle surface fill */
|
||||
|
||||
@@ -40,7 +40,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>();
|
||||
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -165,7 +165,7 @@ async function submit() {
|
||||
if (isNew.value) {
|
||||
const { data } = await api.post<CompanyDto>('/companies', payload());
|
||||
if (props.embedded) {
|
||||
emit('saved');
|
||||
emit('saved', data);
|
||||
return;
|
||||
}
|
||||
await router.replace({ name: 'company-detail', params: { id: data.id } });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Spin as ASpin } from 'ant-design-vue';
|
||||
import { Alert as AAlert, Spin as ASpin } from 'ant-design-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
@@ -13,8 +13,6 @@ import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
|
||||
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
|
||||
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
|
||||
import HomeTopCustomers from '@/components/dashboard/HomeTopCustomers.vue';
|
||||
import { Alert as AAlert } from 'ant-design-vue';
|
||||
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -24,21 +22,11 @@ import type {
|
||||
PaginatedResponse,
|
||||
PipelineBoardDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
HOME_ACTIVITIES_MOCK,
|
||||
HOME_DASHBOARD_MOCK,
|
||||
HOME_PIPELINE_MOCK,
|
||||
isHomeDashboardEmpty,
|
||||
} from './home-dashboard-mock';
|
||||
|
||||
/** پیشنمایش UI با دیتای ماک — برای اتصال به API واقعی بگذارید false */
|
||||
const FORCE_HOME_MOCK = true;
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const usingMock = ref(false);
|
||||
const showMockBanner = ref(true);
|
||||
const loadError = ref('');
|
||||
const home = ref<HomeDashboardDto | null>(null);
|
||||
const activities = ref<ActivityDto[]>([]);
|
||||
const board = ref<PipelineBoardDto | null>(null);
|
||||
@@ -153,21 +141,10 @@ function onAskHotkey(e: KeyboardEvent) {
|
||||
document.getElementById('home-hero-ask')?.focus();
|
||||
}
|
||||
|
||||
function applyHomeMock() {
|
||||
home.value = HOME_DASHBOARD_MOCK;
|
||||
activities.value = HOME_ACTIVITIES_MOCK;
|
||||
board.value = HOME_PIPELINE_MOCK;
|
||||
usingMock.value = true;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onAskHotkey);
|
||||
loading.value = true;
|
||||
if (FORCE_HOME_MOCK) {
|
||||
applyHomeMock();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loadError.value = '';
|
||||
try {
|
||||
const [homeRes, actRes, boardRes] = await Promise.all([
|
||||
api.get<HomeDashboardDto>('/reports/home'),
|
||||
@@ -176,16 +153,16 @@ onMounted(async () => {
|
||||
}),
|
||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
||||
]);
|
||||
if (isHomeDashboardEmpty(homeRes.data)) {
|
||||
applyHomeMock();
|
||||
} else {
|
||||
home.value = homeRes.data;
|
||||
activities.value = actRes.data.items;
|
||||
board.value = boardRes.data;
|
||||
usingMock.value = false;
|
||||
}
|
||||
} catch {
|
||||
applyHomeMock();
|
||||
home.value = homeRes.data;
|
||||
activities.value = actRes.data.items;
|
||||
board.value = boardRes.data;
|
||||
} catch (e) {
|
||||
loadError.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'بارگذاری داشبورد ناموفق بود.',
|
||||
)
|
||||
: 'بارگذاری داشبورد ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -211,13 +188,11 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<a-alert
|
||||
v-if="usingMock && showMockBanner"
|
||||
type="info"
|
||||
closable
|
||||
v-if="loadError"
|
||||
type="error"
|
||||
show-icon
|
||||
class="rounded-xl"
|
||||
message="پیشنمایش با دیتای ماک"
|
||||
description="برای دیدن دادهٔ واقعی، FORCE_HOME_MOCK را در HomeView روی false بگذارید."
|
||||
@close="showMockBanner = false"
|
||||
:message="loadError"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-[120px]">
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||
import { CrmSkeletonCard } from '@/components/skeleton';
|
||||
@@ -63,6 +63,8 @@ const addUnqualified = ref(false);
|
||||
const deleteTarget = ref<LeadStageDto | null>(null);
|
||||
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const bodyEls = new Map<string, HTMLElement>();
|
||||
const dropStageId = ref<string | null>(null);
|
||||
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
||||
let suppressCardClick = false;
|
||||
@@ -469,17 +471,44 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function setBodyEl(stageId: string, el: unknown) {
|
||||
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
|
||||
else bodyEls.delete(stageId);
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
window.setTimeout(() => {
|
||||
suppressCardClick = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
function onDragOver(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
dropStageId.value = stageId;
|
||||
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
@@ -488,8 +517,10 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.stopPropagation();
|
||||
const leadId =
|
||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||
const at = index ?? insertAt.value ?? undefined;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
if (!leadId || moving.value) return;
|
||||
|
||||
const fromCol = columns.value.find((c) =>
|
||||
@@ -502,14 +533,14 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
if (!lead) return;
|
||||
|
||||
const same = fromCol.stage.id === stageId;
|
||||
if (same && index == null) return;
|
||||
if (same && at == null) return;
|
||||
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||||
let insertAt = index == null || index < 0 ? 0 : index;
|
||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||
if (same && insertAt === fromIndex) return;
|
||||
let insertIndex = at == null || at < 0 ? 0 : at;
|
||||
if (same && at != null && at > fromIndex) insertIndex -= 1;
|
||||
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
|
||||
if (same && insertIndex === fromIndex) return;
|
||||
const nextLeads = [...without];
|
||||
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
|
||||
const prev = columns.value.map((c) => ({
|
||||
...c,
|
||||
@@ -534,7 +565,7 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
moving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
|
||||
} catch {
|
||||
columns.value = prev;
|
||||
error.value = 'جابهجایی لید ناموفق بود.';
|
||||
@@ -668,24 +699,34 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<div :style="columnBodyStyle">
|
||||
<div
|
||||
:ref="(el) => setBodyEl(col.stage.id, el)"
|
||||
:style="columnBodyStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(lead, index) in col.leads"
|
||||
:key="lead.id"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event)"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
|
||||
/>
|
||||
<a-card
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="cardBodyStyle"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
opacity: draggingId === lead.id ? 0.45 : 1,
|
||||
opacity: draggingId === lead.id ? 0.4 : 1,
|
||||
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
flexShrink: 0,
|
||||
margin: 0,
|
||||
background: token.colorBgContainer,
|
||||
boxShadow: token.boxShadow,
|
||||
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
|
||||
}"
|
||||
@click="openLead(lead)"
|
||||
>
|
||||
@@ -816,6 +857,11 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
|
||||
/>
|
||||
|
||||
<a-typography-text
|
||||
v-if="!col.leads.length"
|
||||
|
||||
@@ -249,17 +249,19 @@ onMounted(() => {
|
||||
<code>X-Api-Key: mk_…</code>
|
||||
یا
|
||||
<code>Authorization: Bearer mk_…</code>
|
||||
— مسیرها:
|
||||
— خواندن:
|
||||
<code>/api/public/v1/people</code>
|
||||
و
|
||||
<code>/api/public/v1/deals</code>
|
||||
— ثبت لید از سایت بیرونی:
|
||||
<code>POST /api/public/v1/leads</code>
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-form layout="vertical" @finish="createKey">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کلید" required>
|
||||
<a-input v-model:value="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||
<a-input v-model:value="newKeyName" placeholder="مثلاً نام سایت" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
|
||||
@@ -170,349 +170,6 @@ const chartFillStyle = { height: '100%', width: '100%' };
|
||||
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||
type PageMode = 'dashboard' | 'builder';
|
||||
|
||||
/** Rich mock for layout review. Disable with ?mock=0 */
|
||||
function buildMockReportsDashboard(): ReportsDashboardDto {
|
||||
const days = Array.from({ length: 13 }, (_, i) => {
|
||||
const d = i + 1;
|
||||
const incoming = 8 + ((i * 3) % 7);
|
||||
const won = Math.max(1, Math.round(incoming * (0.22 + (i % 5) * 0.04)));
|
||||
return {
|
||||
key: `1405-08-${String(d).padStart(2, '0')}`,
|
||||
label: `${toFaDigits(d)} آبان`,
|
||||
incomingCount: incoming,
|
||||
wonCount: won,
|
||||
conversionPercent: Math.round((won / incoming) * 100),
|
||||
};
|
||||
});
|
||||
const trend = days.map((d, i) => ({
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
value: 18_000_000 + i * 4_500_000 + (i % 3) * 2_000_000,
|
||||
}));
|
||||
return {
|
||||
from: '2026-10-23',
|
||||
to: '2026-11-04',
|
||||
currency: 'IRR',
|
||||
kpis: [
|
||||
{
|
||||
key: 'new_customers',
|
||||
label: 'مشتریان جدید',
|
||||
value: 42,
|
||||
format: 'number',
|
||||
changePercent: 18,
|
||||
changeLabel: 'نسبت به دوره قبل',
|
||||
},
|
||||
{
|
||||
key: 'invoice_count',
|
||||
label: 'تعداد فاکتور',
|
||||
value: 67,
|
||||
format: 'number',
|
||||
changePercent: 9,
|
||||
changeLabel: 'نسبت به دوره قبل',
|
||||
},
|
||||
{
|
||||
key: 'sales_amount',
|
||||
label: 'مبلغ فروش',
|
||||
value: 485_000_000,
|
||||
format: 'money',
|
||||
changePercent: 14,
|
||||
changeLabel: 'نسبت به دوره قبل',
|
||||
},
|
||||
{
|
||||
key: 'avg_invoice',
|
||||
label: 'میانگین فاکتور',
|
||||
value: 7_240_000,
|
||||
format: 'money',
|
||||
changePercent: -3,
|
||||
changeLabel: 'نسبت به دوره قبل',
|
||||
},
|
||||
{
|
||||
key: 'avg_sale',
|
||||
label: 'میانگین فروش',
|
||||
value: 12_100_000,
|
||||
format: 'money',
|
||||
changePercent: 6,
|
||||
changeLabel: 'نسبت به دوره قبل',
|
||||
},
|
||||
],
|
||||
salesTrend: trend,
|
||||
monthlyComparison: [
|
||||
{ key: 'm1', label: 'مرداد', value: 320_000_000 },
|
||||
{ key: 'm2', label: 'شهریور', value: 410_000_000 },
|
||||
{ key: 'm3', label: 'مهر', value: 455_000_000 },
|
||||
{ key: 'm4', label: 'آبان', value: 485_000_000 },
|
||||
],
|
||||
customerGrowth: [
|
||||
{ key: 'g1', label: 'مرداد', value: 18 },
|
||||
{ key: 'g2', label: 'شهریور', value: 24 },
|
||||
{ key: 'g3', label: 'مهر', value: 31 },
|
||||
{ key: 'g4', label: 'آبان', value: 42 },
|
||||
],
|
||||
sources: [
|
||||
{ label: 'اینستاگرام', count: 28, percent: 34, amount: 120_000_000 },
|
||||
{ label: 'معرفی', count: 19, percent: 23, amount: 150_000_000 },
|
||||
{ label: 'سایت', count: 16, percent: 20, amount: 95_000_000 },
|
||||
{ label: 'تبلیغات', count: 12, percent: 15, amount: 70_000_000 },
|
||||
{ label: 'سایر', count: 7, percent: 8, amount: 50_000_000 },
|
||||
],
|
||||
lostReasons: [
|
||||
{ label: 'قیمت', count: 11, percent: 40 },
|
||||
{ label: 'رقیب', count: 8, percent: 29 },
|
||||
{ label: 'زمانبندی', count: 5, percent: 18 },
|
||||
{ label: 'عدم نیاز', count: 4, percent: 13 },
|
||||
],
|
||||
salesByCategory: [
|
||||
{ label: 'نرمافزار', count: 22, percent: 45, amount: 220_000_000 },
|
||||
{ label: 'سرویس', count: 15, percent: 30, amount: 145_000_000 },
|
||||
{ label: 'سختافزار', count: 12, percent: 25, amount: 120_000_000 },
|
||||
],
|
||||
topProducts: [
|
||||
{ name: 'پلن حرفهای', quantity: 18, amount: 180_000_000 },
|
||||
{ name: 'پلن پایه', quantity: 24, amount: 96_000_000 },
|
||||
{ name: 'پشتیبانی سالانه', quantity: 12, amount: 72_000_000 },
|
||||
{ name: 'ماژول پیامک', quantity: 9, amount: 45_000_000 },
|
||||
{ name: 'آموزش', quantity: 7, amount: 28_000_000 },
|
||||
],
|
||||
customerStatus: [
|
||||
{ label: 'فعال', count: 86, percent: 58 },
|
||||
{ label: 'در حال پیگیری', count: 34, percent: 23 },
|
||||
{ label: 'راکد', count: 28, percent: 19 },
|
||||
],
|
||||
activities: {
|
||||
open: 24,
|
||||
done: 58,
|
||||
overdue: 7,
|
||||
byType: [
|
||||
{ type: 'call', label: 'تماس', count: 32 },
|
||||
{ type: 'meeting', label: 'جلسه', count: 18 },
|
||||
{ type: 'task', label: 'وظیفه', count: 21 },
|
||||
{ type: 'email', label: 'ایمیل', count: 11 },
|
||||
{ type: 'note', label: 'یادداشت', count: 7 },
|
||||
],
|
||||
},
|
||||
team: [
|
||||
{
|
||||
userId: 'u1',
|
||||
fullName: 'مدیر نمونه',
|
||||
wonAmount: 165_000_000,
|
||||
wonCount: 9,
|
||||
activitiesDone: 22,
|
||||
},
|
||||
{
|
||||
userId: 'u2',
|
||||
fullName: 'کارشناس فروش نمونه',
|
||||
wonAmount: 142_000_000,
|
||||
wonCount: 11,
|
||||
activitiesDone: 31,
|
||||
},
|
||||
{
|
||||
userId: 'u3',
|
||||
fullName: 'آقای بنی لوحی',
|
||||
wonAmount: 98_000_000,
|
||||
wonCount: 6,
|
||||
activitiesDone: 14,
|
||||
},
|
||||
{
|
||||
userId: 'u4',
|
||||
fullName: 'مدیر سازمانی نمونه',
|
||||
wonAmount: 80_000_000,
|
||||
wonCount: 5,
|
||||
activitiesDone: 10,
|
||||
},
|
||||
],
|
||||
insights: [
|
||||
'نرخ تبدیل اینستاگرام ۱۸٪ بالاتر از میانگین منابع است؛ بودجه تبلیغ را اینجا نگه دارید.',
|
||||
'مرحله «قیمت و نهایی کردن» بیشترین ریزش را دارد؛ اسکریپت تخفیف کنترلشده پیشنهاد میشود.',
|
||||
'دو فروشنده بالای میانگین تیم هستند؛ جلسات کپیروش فروش را با آنها برگزار کنید.',
|
||||
],
|
||||
sales: {
|
||||
openCount: 38,
|
||||
wonCount: 31,
|
||||
lostCount: 28,
|
||||
openAmount: 620_000_000,
|
||||
wonAmount: 485_000_000,
|
||||
lostAmount: 210_000_000,
|
||||
winRate: 52,
|
||||
},
|
||||
funnel: [
|
||||
{
|
||||
stageId: 's1',
|
||||
stageName: 'لید جدید',
|
||||
pipelineId: 'p1',
|
||||
pipelineName: 'مسیر فروش اصلی',
|
||||
count: 24,
|
||||
amount: 180_000_000,
|
||||
conversionFromPrevPercent: null,
|
||||
shareOfPipelinePercent: 35,
|
||||
},
|
||||
{
|
||||
stageId: 's2',
|
||||
stageName: 'اولین تماس',
|
||||
pipelineId: 'p1',
|
||||
pipelineName: 'مسیر فروش اصلی',
|
||||
count: 16,
|
||||
amount: 140_000_000,
|
||||
conversionFromPrevPercent: 67,
|
||||
shareOfPipelinePercent: 24,
|
||||
},
|
||||
{
|
||||
stageId: 's3',
|
||||
stageName: 'مذاکرات اولیه',
|
||||
pipelineId: 'p1',
|
||||
pipelineName: 'مسیر فروش اصلی',
|
||||
count: 12,
|
||||
amount: 160_000_000,
|
||||
conversionFromPrevPercent: 75,
|
||||
shareOfPipelinePercent: 18,
|
||||
},
|
||||
{
|
||||
stageId: 's4',
|
||||
stageName: 'پیشنهاد قیمت',
|
||||
pipelineId: 'p1',
|
||||
pipelineName: 'مسیر فروش اصلی',
|
||||
count: 9,
|
||||
amount: 190_000_000,
|
||||
conversionFromPrevPercent: 75,
|
||||
shareOfPipelinePercent: 13,
|
||||
},
|
||||
{
|
||||
stageId: 's5',
|
||||
stageName: 'قیمت و نهایی کردن',
|
||||
pipelineId: 'p1',
|
||||
pipelineName: 'مسیر فروش اصلی',
|
||||
count: 7,
|
||||
amount: 210_000_000,
|
||||
conversionFromPrevPercent: 78,
|
||||
shareOfPipelinePercent: 10,
|
||||
},
|
||||
],
|
||||
conversionRate: {
|
||||
incomingCount: 94,
|
||||
wonCount: 31,
|
||||
conversionPercent: 33,
|
||||
previousIncomingCount: 81,
|
||||
previousWonCount: 22,
|
||||
previousConversionPercent: 27,
|
||||
changePercent: 6,
|
||||
byTime: days,
|
||||
byUser: [
|
||||
{
|
||||
key: 'u2',
|
||||
label: 'کارشناس فروش نمونه',
|
||||
incomingCount: 34,
|
||||
wonCount: 14,
|
||||
conversionPercent: 41,
|
||||
},
|
||||
{
|
||||
key: 'u1',
|
||||
label: 'مدیر نمونه',
|
||||
incomingCount: 28,
|
||||
wonCount: 9,
|
||||
conversionPercent: 32,
|
||||
},
|
||||
{
|
||||
key: 'u3',
|
||||
label: 'آقای بنی لوحی',
|
||||
incomingCount: 20,
|
||||
wonCount: 5,
|
||||
conversionPercent: 25,
|
||||
},
|
||||
{
|
||||
key: 'u4',
|
||||
label: 'مدیر سازمانی نمونه',
|
||||
incomingCount: 12,
|
||||
wonCount: 3,
|
||||
conversionPercent: 25,
|
||||
},
|
||||
],
|
||||
bySource: [
|
||||
{
|
||||
key: 'ig',
|
||||
label: 'اینستاگرام',
|
||||
incomingCount: 30,
|
||||
wonCount: 12,
|
||||
conversionPercent: 40,
|
||||
},
|
||||
{
|
||||
key: 'ref',
|
||||
label: 'معرفی',
|
||||
incomingCount: 22,
|
||||
wonCount: 9,
|
||||
conversionPercent: 41,
|
||||
},
|
||||
{
|
||||
key: 'web',
|
||||
label: 'سایت',
|
||||
incomingCount: 24,
|
||||
wonCount: 6,
|
||||
conversionPercent: 25,
|
||||
},
|
||||
{
|
||||
key: 'ads',
|
||||
label: 'تبلیغات',
|
||||
incomingCount: 18,
|
||||
wonCount: 4,
|
||||
conversionPercent: 22,
|
||||
},
|
||||
],
|
||||
},
|
||||
periodCompare: [
|
||||
{
|
||||
key: 'won_amount',
|
||||
label: 'مبلغ فروش موفق',
|
||||
format: 'money',
|
||||
current: 485_000_000,
|
||||
previous: 410_000_000,
|
||||
changePercent: 18,
|
||||
drillKind: 'period_won',
|
||||
},
|
||||
{
|
||||
key: 'won_count',
|
||||
label: 'تعداد فروش موفق',
|
||||
format: 'number',
|
||||
current: 31,
|
||||
previous: 26,
|
||||
changePercent: 19,
|
||||
drillKind: 'period_won',
|
||||
},
|
||||
{
|
||||
key: 'open_amount',
|
||||
label: 'معاملات باز',
|
||||
format: 'money',
|
||||
current: 620_000_000,
|
||||
previous: 540_000_000,
|
||||
changePercent: 15,
|
||||
},
|
||||
{
|
||||
key: 'win_rate',
|
||||
label: 'نرخ برد',
|
||||
format: 'percent',
|
||||
current: 52,
|
||||
previous: 47,
|
||||
changePercent: 5,
|
||||
},
|
||||
{
|
||||
key: 'new_people',
|
||||
label: 'مشتریان جدید',
|
||||
format: 'number',
|
||||
current: 42,
|
||||
previous: 35,
|
||||
changePercent: 20,
|
||||
},
|
||||
],
|
||||
tickets: {
|
||||
open: 6,
|
||||
resolved: 14,
|
||||
closed: 9,
|
||||
slaBreached: 1,
|
||||
avgCsat: 4.2,
|
||||
csatCount: 11,
|
||||
},
|
||||
peopleCreated: 42,
|
||||
invoiceCount: 67,
|
||||
invoiceAmount: 485_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
||||
{ key: 'today', label: 'امروز' },
|
||||
@@ -1201,14 +858,7 @@ onMounted(() => {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const useMock = route.query.mock !== '0';
|
||||
try {
|
||||
if (useMock) {
|
||||
const orgRes = await api.get<OrganizationDto>('/organizations/current');
|
||||
org.value = orgRes.data;
|
||||
data.value = buildMockReportsDashboard();
|
||||
return;
|
||||
}
|
||||
const [dash, orgRes] = await Promise.all([
|
||||
api.get<ReportsDashboardDto>('/reports/dashboard', {
|
||||
params: {
|
||||
@@ -1221,11 +871,6 @@ async function load() {
|
||||
data.value = dash.data;
|
||||
org.value = orgRes.data;
|
||||
} catch (e) {
|
||||
if (useMock) {
|
||||
data.value = buildMockReportsDashboard();
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
UpdateTahesabConnectionRequest,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -103,6 +103,91 @@ const syncTablePageSize = ref(50);
|
||||
const syncTablePage = ref(1);
|
||||
|
||||
const syncOnlyNew = ref(false);
|
||||
const autoCount = ref('0');
|
||||
const autoHours = ref<string[]>([]);
|
||||
const autoSyncSaving = ref(false);
|
||||
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
|
||||
1: [8],
|
||||
2: [8, 14],
|
||||
3: [8, 12, 16],
|
||||
4: [8, 12, 16, 20],
|
||||
5: [8, 11, 14, 17, 20],
|
||||
6: [8, 10, 12, 14, 16, 20],
|
||||
};
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
items.value.find((row) => row.id === sync.connectionId),
|
||||
);
|
||||
|
||||
function hourLabel(hour: number) {
|
||||
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
|
||||
}
|
||||
|
||||
const autoCountOptions = [
|
||||
{ value: '0', label: 'خاموش' },
|
||||
...Array.from({ length: 6 }, (_, index) => ({
|
||||
value: String(index + 1),
|
||||
label: `${toFaDigits(index + 1)} بار`,
|
||||
})),
|
||||
];
|
||||
|
||||
const hourOptions = Array.from({ length: 24 }, (_, hour) => ({
|
||||
value: String(hour),
|
||||
label: hourLabel(hour),
|
||||
}));
|
||||
|
||||
function fillAutoSync() {
|
||||
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 6);
|
||||
autoCount.value = String(hours.length);
|
||||
autoHours.value = hours.map(String);
|
||||
}
|
||||
|
||||
function onAutoCount(value: string) {
|
||||
const count = Math.min(6, Math.max(0, Number(value) || 0));
|
||||
const preset = AUTO_HOUR_PRESETS[count] ?? [];
|
||||
const next = autoHours.value.slice(0, count);
|
||||
while (next.length < count) {
|
||||
next.push(String(preset[next.length] ?? 8));
|
||||
}
|
||||
autoCount.value = String(count);
|
||||
autoHours.value = next;
|
||||
}
|
||||
|
||||
function setAutoHour(index: number, value: string) {
|
||||
autoHours.value[index] = value;
|
||||
}
|
||||
|
||||
async function saveAutoSync() {
|
||||
if (!sync.connectionId) return;
|
||||
const hours = autoHours.value.map((hour) => Number(hour));
|
||||
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
|
||||
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
|
||||
return;
|
||||
}
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
error.value = 'ساعتها نباید تکراری باشند.';
|
||||
return;
|
||||
}
|
||||
autoSyncSaving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
|
||||
customerSyncHours: hours,
|
||||
});
|
||||
ok.value = hours.length
|
||||
? 'زمانبندی بروزرسانی ذخیره شد.'
|
||||
: 'بروزرسانی خودکار خاموش شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'ذخیره زمانبندی ناموفق بود');
|
||||
} finally {
|
||||
autoSyncSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const syncVisibleResults = computed(() =>
|
||||
syncOnlyNew.value
|
||||
? syncResults.value.filter((r) => !r.alreadyLinked)
|
||||
@@ -115,6 +200,7 @@ const syncPagedResults = computed(() => {
|
||||
const start = (syncTablePage.value - 1) * syncTablePageSize.value;
|
||||
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
|
||||
});
|
||||
watch(() => sync.connectionId, () => fillAutoSync());
|
||||
watch([syncOnlyNew, syncTablePageSize], () => {
|
||||
syncTablePage.value = 1;
|
||||
});
|
||||
@@ -207,6 +293,7 @@ async function load() {
|
||||
if (!editingId.value) form.isDefault = data.length === 0;
|
||||
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
||||
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
|
||||
fillAutoSync();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
@@ -964,6 +1051,53 @@ onMounted(load);
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-card type="inner" title="بروزرسانی خودکار">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعتها، به وقت تهران،
|
||||
مشتریان تازه را وارد میکند و نام مشتریهای قبلی را هم با تهحساب تازه میکند. اگر شماره یکی باشد
|
||||
همان مشتری میماند، نام قبلی حفظ میشود و شماره به شکل ۰۹۱۲ ذخیره میشود. خاموش یعنی فقط ورود دستی.
|
||||
</a-typography-text>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تعداد در روز">
|
||||
<a-select
|
||||
:value="autoCount"
|
||||
:options="autoCountOptions"
|
||||
:disabled="!sync.connectionId || autoSyncSaving"
|
||||
@update:value="(value) => onAutoCount(String(value ?? '0'))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-for="(_, index) in autoHours" :key="index" :xs="24" :md="8">
|
||||
<a-form-item :label="`ساعت ${toFaDigits(index + 1)}`">
|
||||
<a-select
|
||||
:value="autoHours[index]"
|
||||
:options="hourOptions"
|
||||
:disabled="autoSyncSaving"
|
||||
@update:value="(value) => setAutoHour(index, String(value ?? '8'))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button :loading="autoSyncSaving" :disabled="!sync.connectionId" @click="saveAutoSync">
|
||||
ذخیره زمانبندی
|
||||
</a-button>
|
||||
<a-typography-text v-if="selectedConnection?.customerSyncLastRunAt" type="secondary">
|
||||
آخرین اجرا:
|
||||
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
|
||||
<template v-if="selectedConnection.customerSyncLastSummary">
|
||||
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="selectedConnection?.customerSyncLastError" type="danger">
|
||||
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card type="inner" title="۱) وارد کردن دستهای با بازه کد مشتری">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import type {
|
||||
ActivityDto,
|
||||
HomeDashboardDto,
|
||||
PipelineBoardDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
/** پیشنمایش داشبورد خانه وقتی API خالی است یا FORCE روشن باشد */
|
||||
export const HOME_DASHBOARD_MOCK: HomeDashboardDto = {
|
||||
currency: 'IRR',
|
||||
kpis: [
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'فروش این ماه',
|
||||
value: 186_500_000,
|
||||
changePercent: 18.4,
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'customers',
|
||||
label: 'مشتریان جدید',
|
||||
value: 42,
|
||||
changePercent: 12.5,
|
||||
changeLabel: 'نسبت به ماه قبل',
|
||||
},
|
||||
{
|
||||
key: 'open-deals',
|
||||
label: 'معاملات باز',
|
||||
value: 27,
|
||||
changePercent: -4.2,
|
||||
changeLabel: 'نسبت به هفته قبل',
|
||||
},
|
||||
{
|
||||
key: 'followups',
|
||||
label: 'پیگیری امروز',
|
||||
value: 9,
|
||||
changePercent: 25,
|
||||
changeLabel: 'نسبت به دیروز',
|
||||
},
|
||||
{
|
||||
key: 'win-rate',
|
||||
label: 'نرخ برد',
|
||||
value: 58.3,
|
||||
changePercent: null,
|
||||
changeLabel: '۱۲ ماه اخیر',
|
||||
},
|
||||
{
|
||||
key: 'overdue',
|
||||
label: 'عقبافتاده',
|
||||
value: 6,
|
||||
changePercent: null,
|
||||
changeLabel: 'فعالیت باز',
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
key: 'stale-deal',
|
||||
tone: 'urgent',
|
||||
text: 'شرکت نمونه آکمه در مرحله تصمیمگیری است — امروز تماس بگیرید',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'stale-count',
|
||||
tone: 'warning',
|
||||
text: '۵ معامله بیش از ۵ روز پیگیری نشده',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'overdue-acts',
|
||||
tone: 'warning',
|
||||
text: '۶ پیگیری عقبافتاده دارید — همین امروز ببندید',
|
||||
link: '/activities',
|
||||
},
|
||||
{
|
||||
key: 'target-close',
|
||||
tone: 'info',
|
||||
text: 'به تارگت ماه نزدیک شدهاید',
|
||||
link: '/sales/forecast',
|
||||
},
|
||||
{
|
||||
key: 'big-deal',
|
||||
tone: 'info',
|
||||
text: 'پیشنهاد ۱۱۲ میلیون تومانی سپهر در انتظار تأیید است',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'win-streak',
|
||||
tone: 'success',
|
||||
text: 'این هفته ۳ معامله برنده بستهاید',
|
||||
link: '/reports',
|
||||
},
|
||||
{
|
||||
key: 'meeting-today',
|
||||
tone: 'urgent',
|
||||
text: 'جلسه دمو با پارسنوین ساعت ۱۴ — آمادهسازی پیشنهاد',
|
||||
link: '/activities',
|
||||
},
|
||||
{
|
||||
key: 'quote-expire',
|
||||
tone: 'warning',
|
||||
text: 'پیشفاکتور صنایع سپهر تا فردا منقضی میشود',
|
||||
link: '/pipeline',
|
||||
},
|
||||
{
|
||||
key: 'new-lead',
|
||||
tone: 'info',
|
||||
text: '۴ لید جدید از فرم وبسایت بدون مالک ماندهاند',
|
||||
link: '/contacts',
|
||||
},
|
||||
{
|
||||
key: 'renewal',
|
||||
tone: 'info',
|
||||
text: 'قرارداد تمدید سارا محمدی این ماه سررسید میشود',
|
||||
link: '/pipeline',
|
||||
},
|
||||
],
|
||||
salesTrend: [
|
||||
{ monthKey: '1403-07', monthLabel: 'مهر', amount: 64_000_000 },
|
||||
{ monthKey: '1403-08', monthLabel: 'آبان', amount: 71_500_000 },
|
||||
{ monthKey: '1403-09', monthLabel: 'آذر', amount: 68_200_000 },
|
||||
{ monthKey: '1403-10', monthLabel: 'دی', amount: 79_800_000 },
|
||||
{ monthKey: '1403-11', monthLabel: 'بهمن', amount: 88_400_000 },
|
||||
{ monthKey: '1403-12', monthLabel: 'اسفند', amount: 95_000_000 },
|
||||
{ monthKey: '1404-01', monthLabel: 'فروردین', amount: 82_600_000 },
|
||||
{ monthKey: '1404-02', monthLabel: 'اردیبهشت', amount: 92_000_000 },
|
||||
{ monthKey: '1404-03', monthLabel: 'خرداد', amount: 118_500_000 },
|
||||
{ monthKey: '1404-04', monthLabel: 'تیر', amount: 141_200_000 },
|
||||
{ monthKey: '1404-05', monthLabel: 'مرداد', amount: 157_800_000 },
|
||||
{ monthKey: '1404-06', monthLabel: 'شهریور', amount: 186_500_000 },
|
||||
],
|
||||
customerGrowth: [
|
||||
{ monthKey: '1403-07', monthLabel: 'مهر', value: 11 },
|
||||
{ monthKey: '1403-08', monthLabel: 'آبان', value: 14 },
|
||||
{ monthKey: '1403-09', monthLabel: 'آذر', value: 13 },
|
||||
{ monthKey: '1403-10', monthLabel: 'دی', value: 16 },
|
||||
{ monthKey: '1403-11', monthLabel: 'بهمن', value: 19 },
|
||||
{ monthKey: '1403-12', monthLabel: 'اسفند', value: 22 },
|
||||
{ monthKey: '1404-01', monthLabel: 'فروردین', value: 15 },
|
||||
{ monthKey: '1404-02', monthLabel: 'اردیبهشت', value: 18 },
|
||||
{ monthKey: '1404-03', monthLabel: 'خرداد', value: 24 },
|
||||
{ monthKey: '1404-04', monthLabel: 'تیر', value: 31 },
|
||||
{ monthKey: '1404-05', monthLabel: 'مرداد', value: 28 },
|
||||
{ monthKey: '1404-06', monthLabel: 'شهریور', value: 42 },
|
||||
],
|
||||
sources: [
|
||||
{ label: 'فرم لید', count: 38, percent: 32.2 },
|
||||
{ label: 'معرفی دوستان', count: 29, percent: 24.6 },
|
||||
{ label: 'وبسایت', count: 21, percent: 17.8 },
|
||||
{ label: 'اکسل', count: 16, percent: 13.6 },
|
||||
{ label: 'تلفن', count: 8, percent: 6.8 },
|
||||
{ label: 'سایر', count: 6, percent: 5 },
|
||||
],
|
||||
topCustomers: [
|
||||
{ id: 'mock-c1', name: 'شرکت آکمه', dealCount: 7, rank: 1 },
|
||||
{ id: 'mock-c2', name: 'گروه پارسنوین', dealCount: 5, rank: 2 },
|
||||
{ id: 'mock-c3', name: 'سارا محمدی', dealCount: 4, rank: 3 },
|
||||
{ id: 'mock-c4', name: 'صنایع سپهر', dealCount: 3, rank: 4 },
|
||||
{ id: 'mock-c5', name: 'رضا کریمی', dealCount: 3, rank: 5 },
|
||||
],
|
||||
salesSummary: {
|
||||
openCount: 27,
|
||||
wonCount: 35,
|
||||
lostCount: 25,
|
||||
openAmount: 412_000_000,
|
||||
wonAmount: 696_000_000,
|
||||
lostAmount: 218_000_000,
|
||||
winRate: 58.3,
|
||||
},
|
||||
funnel: [
|
||||
{ stageId: 's1', stageName: 'تماس اول', count: 11, amount: 95_000_000 },
|
||||
{ stageId: 's2', stageName: 'نیازسنجی', count: 8, amount: 128_000_000 },
|
||||
{ stageId: 's3', stageName: 'پیشنهاد', count: 5, amount: 112_000_000 },
|
||||
{ stageId: 's4', stageName: 'مذاکره', count: 3, amount: 77_000_000 },
|
||||
],
|
||||
lostReasons: [
|
||||
{ label: 'قیمت', count: 9, percent: 36, amount: 82_000_000 },
|
||||
{ label: 'رقیب', count: 7, percent: 28, amount: 61_000_000 },
|
||||
{ label: 'زمانبندی', count: 5, percent: 20, amount: 44_000_000 },
|
||||
{ label: 'بدون دلیل ثبتشده', count: 4, percent: 16, amount: 31_000_000 },
|
||||
],
|
||||
activityByType: [
|
||||
{ type: 'call', label: 'تماس', count: 14 },
|
||||
{ type: 'meeting', label: 'جلسه', count: 7 },
|
||||
{ type: 'task', label: 'کار', count: 11 },
|
||||
{ type: 'note', label: 'یادداشت', count: 4 },
|
||||
],
|
||||
};
|
||||
|
||||
function hoursFromNow(hours: number) {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + hours);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export const HOME_ACTIVITIES_MOCK = [
|
||||
{
|
||||
id: 'mock-act-1',
|
||||
type: 'call',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
title: 'تماس پیگیری پیشنهاد آکمه',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(3),
|
||||
durationMinutes: 30,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: null,
|
||||
tags: [],
|
||||
person: { id: 'p1', fullName: 'علی رضایی' },
|
||||
company: { id: 'c1', name: 'شرکت آکمه' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-20),
|
||||
updatedAt: hoursFromNow(-2),
|
||||
},
|
||||
{
|
||||
id: 'mock-act-2',
|
||||
type: 'meeting',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
title: 'جلسه دمو با پارسنوین',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(26),
|
||||
durationMinutes: 60,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: null,
|
||||
tags: [],
|
||||
person: { id: 'p2', fullName: 'مریم احمدی' },
|
||||
company: { id: 'c2', name: 'گروه پارسنوین' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-48),
|
||||
updatedAt: hoursFromNow(-10),
|
||||
},
|
||||
{
|
||||
id: 'mock-act-3',
|
||||
type: 'task',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
title: 'ارسال پیشفاکتور سپهر',
|
||||
description: null,
|
||||
dueAt: hoursFromNow(-5),
|
||||
durationMinutes: null,
|
||||
reminderAt: null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
reminderSentAt: null,
|
||||
completedAt: hoursFromNow(-4),
|
||||
tags: [],
|
||||
person: null,
|
||||
company: { id: 'c3', name: 'صنایع سپهر' },
|
||||
deal: null,
|
||||
assignee: null,
|
||||
assignees: [],
|
||||
createdBy: null,
|
||||
createdAt: hoursFromNow(-72),
|
||||
updatedAt: hoursFromNow(-4),
|
||||
},
|
||||
] as unknown as ActivityDto[];
|
||||
|
||||
export const HOME_PIPELINE_MOCK = {
|
||||
pipeline: {
|
||||
id: 'mock-pipe',
|
||||
name: 'فروش اصلی',
|
||||
isDefault: true,
|
||||
createdAt: hoursFromNow(-1000),
|
||||
updatedAt: hoursFromNow(-1),
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'تماس اول',
|
||||
position: 0,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 95_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'لایسنس سازمانی',
|
||||
amount: 45_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c1', name: 'شرکت آکمه' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-30),
|
||||
createdAt: hoursFromNow(-200),
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
title: 'پکیج پشتیبانی',
|
||||
amount: 22_000_000,
|
||||
currency: 'IRR',
|
||||
company: null,
|
||||
person: { id: 'p3', fullName: 'سارا محمدی' },
|
||||
updatedAt: hoursFromNow(-8),
|
||||
createdAt: hoursFromNow(-90),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
name: 'نیازسنجی',
|
||||
position: 1,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 128_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd3',
|
||||
title: 'ارتقای CRM',
|
||||
amount: 68_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c2', name: 'گروه پارسنوین' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-12),
|
||||
createdAt: hoursFromNow(-140),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
name: 'پیشنهاد',
|
||||
position: 2,
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
totalAmount: 112_000_000,
|
||||
deals: [
|
||||
{
|
||||
id: 'd4',
|
||||
title: 'پروژه یکپارچهسازی',
|
||||
amount: 112_000_000,
|
||||
currency: 'IRR',
|
||||
company: { id: 'c3', name: 'صنایع سپهر' },
|
||||
person: null,
|
||||
updatedAt: hoursFromNow(-2),
|
||||
createdAt: hoursFromNow(-60),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as PipelineBoardDto;
|
||||
|
||||
export function isHomeDashboardEmpty(data: HomeDashboardDto | null | undefined): boolean {
|
||||
if (!data) return true;
|
||||
const salesFlat = (data.salesTrend ?? []).every((p) => !p.amount);
|
||||
const growthFlat = (data.customerGrowth ?? []).every((p) => !p.value);
|
||||
const noSources = !(data.sources ?? []).length;
|
||||
const kpisFlat = (data.kpis ?? []).every((k) => !k.value);
|
||||
return (salesFlat && growthFlat && noSources) || kpisFlat;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -30,13 +30,18 @@ import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/Re
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const selfId = computed(() => auth.user?.id ?? '');
|
||||
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
|
||||
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
|
||||
const isEdit = computed(() => !!id.value);
|
||||
@@ -76,6 +81,8 @@ const recurrence = ref<RecurrenceValue>({
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const loaded = ref<RoutineDto | null>(null);
|
||||
const foreign = ref(false);
|
||||
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
|
||||
@@ -140,10 +147,14 @@ function fill(r: RoutineDto) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id.value) return;
|
||||
if (!id.value) {
|
||||
if (selfId.value) form.assigneeId = selfId.value;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
|
||||
loaded.value = data;
|
||||
fill(data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'روتین پیدا نشد.');
|
||||
@@ -152,6 +163,23 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => shell.context.value, loaded, selfId],
|
||||
() => {
|
||||
const ctx = shell.context.value;
|
||||
const data = loaded.value;
|
||||
if (!ctx || !data || ctx.isManager) {
|
||||
foreign.value = false;
|
||||
return;
|
||||
}
|
||||
foreign.value = data.createdById !== selfId.value;
|
||||
if (foreign.value) {
|
||||
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش میتواند تغییرش دهد.';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
@@ -198,6 +226,11 @@ const summary = computed(() => {
|
||||
});
|
||||
|
||||
function validate(): string | null {
|
||||
if (!isManager.value) {
|
||||
form.target = 'user';
|
||||
form.assigneeId = selfId.value;
|
||||
form.teamId = '';
|
||||
}
|
||||
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
|
||||
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
|
||||
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
|
||||
@@ -249,7 +282,7 @@ async function save() {
|
||||
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
|
||||
else await api.post('/routines', body);
|
||||
shell.notifyChanged();
|
||||
void router.push('/routines/manage');
|
||||
void router.push(backTo.value);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
|
||||
} finally {
|
||||
@@ -263,7 +296,7 @@ async function save() {
|
||||
<a-flex vertical :gap="4">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink to="/routines/manage">مدیریت روتینها</RouterLink>
|
||||
<RouterLink :to="backTo">{{ isManager ? 'مدیریت روتینها' : 'روتینهای من' }}</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
@@ -275,7 +308,9 @@ async function save() {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<CrmSkeleton :loading="loading">
|
||||
<a-alert v-if="foreign" type="error" show-icon :message="error" />
|
||||
|
||||
<CrmSkeleton v-if="!foreign" :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-row :gutter="[16, 16]">
|
||||
@@ -323,6 +358,10 @@ async function save() {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" title="مسئول">
|
||||
<a-typography-text v-if="!isManager" type="secondary">
|
||||
این روتین فقط برای خودتان ساخته میشود.
|
||||
</a-typography-text>
|
||||
<template v-else>
|
||||
<a-form-item>
|
||||
<a-radio-group v-model:value="form.target" button-style="solid">
|
||||
<a-radio-button value="user">یک کاربر</a-radio-button>
|
||||
@@ -354,6 +393,7 @@ async function save() {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" title="زمانبندی و تکرار">
|
||||
@@ -484,7 +524,7 @@ async function save() {
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}
|
||||
</a-button>
|
||||
<a-button block :disabled="saving" @click="router.push('/routines/manage')">
|
||||
<a-button block :disabled="saving" @click="router.push(backTo)">
|
||||
انصراف
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineDto } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -35,6 +37,8 @@ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyPa
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const meId = computed(() => auth.user?.id ?? '');
|
||||
|
||||
const items = ref<RoutineDto[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -90,7 +94,7 @@ const TONE_COLORS: Record<string, string> = {
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-typography-text type="secondary">
|
||||
روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.
|
||||
روتینهای شما. کارهای هر روز از روی اینها ساخته میشوند. با «روتین جدید» میتوانید برای خودتان برنامه تکراری بسازید.
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -116,7 +120,7 @@ const TONE_COLORS: Record<string, string> = {
|
||||
:description="
|
||||
q
|
||||
? 'روتینی با این عنوان پیدا نشد'
|
||||
: 'هنوز روتینی برای شما تعریف نشده؛ روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند.'
|
||||
: 'هنوز روتینی برای شما نیست. با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده میشود.'
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -170,9 +174,14 @@ const TONE_COLORS: Record<string, string> = {
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(r.executionRate)]"
|
||||
/>
|
||||
<a-typography-text type="secondary">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</a-typography-text>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</a-typography-text>
|
||||
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
@@ -98,7 +98,7 @@ const tabs: Tab[] = [
|
||||
|
||||
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
|
||||
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
|
||||
|
||||
watch(
|
||||
[() => route.name, context],
|
||||
@@ -145,7 +145,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
|
||||
>
|
||||
<template #icon><BellOutlined /></template>
|
||||
</a-button>
|
||||
<a-button v-if="isManager" @click="router.push('/routines/manage/new')">
|
||||
<a-button @click="router.push('/routines/manage/new')">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
روتین جدید
|
||||
</a-button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -13,20 +13,38 @@ import {
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Popconfirm as APopconfirm,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
|
||||
const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
|
||||
const error = ref('');
|
||||
const saving = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const edit = ref({ id: '', name: '', isActive: true });
|
||||
|
||||
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
|
||||
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
|
||||
@@ -35,6 +53,7 @@ async function load() {
|
||||
|
||||
async function add() {
|
||||
error.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/treasury/categories', {
|
||||
name: form.value.name,
|
||||
@@ -43,66 +62,160 @@ async function add() {
|
||||
form.value.name = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(item: FinancialCategoryDto) {
|
||||
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function remove(item: FinancialCategoryDto) {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/treasury/categories/${item.id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
const name = edit.value.name.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return Promise.reject(new Error('invalid'));
|
||||
}
|
||||
editSaving.value = true;
|
||||
error.value = '';
|
||||
return api
|
||||
.patch(`/treasury/categories/${edit.value.id}`, {
|
||||
name,
|
||||
isActive: edit.value.isActive,
|
||||
})
|
||||
.then(() => load())
|
||||
.catch((e: unknown) => {
|
||||
error.value = apiMessage(e);
|
||||
return Promise.reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
editSaving.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-typography-text type="secondary">
|
||||
دستهها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده میشوند.
|
||||
</a-typography-text>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<a-form layout="vertical" :model="form" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نام"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">دسته جدید</a-divider>
|
||||
<a-form layout="vertical" :model="form" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نام"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">درآمد</a-divider>
|
||||
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="دستهای ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<a-divider orientation="right">هزینه</a-divider>
|
||||
<a-empty v-if="!expenseRows.length" description="دسته هزینهای نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
title="ویرایش دسته"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="editSaving"
|
||||
@ok="saveEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="edit.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال در فرمها">
|
||||
<a-switch v-model:checked="edit.isActive" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -19,8 +19,17 @@ import {
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryPartyBalanceDto,
|
||||
} from '@mento/shared';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
|
||||
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
|
||||
|
||||
const kind = 'expense' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
@@ -41,13 +51,14 @@ const form = ref({
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
partyKind: undefined as FinancialPartyKind | undefined,
|
||||
partyId: undefined as string | undefined,
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
const settleMirror = useSettleDefault(form);
|
||||
|
||||
const settleLabel = 'مبلغ پرداختشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -71,13 +82,29 @@ function apiMessage(e: unknown) {
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc] = await Promise.all([
|
||||
const [docs, cats, acc, bals] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
balances.value = bals.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
@@ -96,31 +123,44 @@ async function createDoc() {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||
error.value = 'طرف حساب را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||
error.value = 'برای سایر، مبلغ پرداختشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
partyKind: form.value.partyKind,
|
||||
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
|
||||
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
|
||||
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
settleMirror.reset();
|
||||
form.value = {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
partyKind: undefined,
|
||||
partyId: undefined,
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -180,7 +220,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -198,8 +238,8 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
<a-form-item label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -225,15 +265,13 @@ onMounted(() => {
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">مانده پرداختنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداختشده" />
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
@@ -242,7 +280,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -268,11 +306,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
|
||||
@@ -45,11 +45,8 @@ import type {
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import { api } from '@/lib/api';
|
||||
import axios from 'axios';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import {
|
||||
TREASURY_DASHBOARD_MOCK,
|
||||
isTreasuryDashboardEmpty,
|
||||
} from './treasury-dashboard-mock';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
use([CanvasRenderer, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
@@ -92,7 +89,6 @@ type MetricCard = {
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
const usingMock = ref(false);
|
||||
|
||||
const heroCards: MetricCard[] = [
|
||||
{
|
||||
@@ -288,19 +284,18 @@ const mixChartOption = computed<ChartOption>(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
usingMock.value = false;
|
||||
error.value = '';
|
||||
try {
|
||||
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
|
||||
if (isTreasuryDashboardEmpty(res.data)) {
|
||||
data.value = TREASURY_DASHBOARD_MOCK;
|
||||
usingMock.value = true;
|
||||
} else {
|
||||
data.value = res.data;
|
||||
}
|
||||
} catch {
|
||||
data.value = TREASURY_DASHBOARD_MOCK;
|
||||
usingMock.value = true;
|
||||
error.value = '';
|
||||
data.value = res.data;
|
||||
} catch (e) {
|
||||
data.value = null;
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'بارگذاری داشبورد خزانه ناموفق بود.',
|
||||
)
|
||||
: 'بارگذاری داشبورد خزانه ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -311,13 +306,6 @@ onMounted(async () => {
|
||||
<div>
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert
|
||||
v-if="usingMock"
|
||||
type="info"
|
||||
show-icon
|
||||
message="در حال نمایش دادهٔ نمونه (ماک) برای پیشنمایش داشبورد هستید."
|
||||
description="وقتی سند و حساب واقعی ثبت شود، همین صفحه دادهٔ واقعی API را نشان میدهد."
|
||||
/>
|
||||
|
||||
<CrmSkeleton :loading="loading">
|
||||
<a-row :gutter="[token.marginSM, token.marginSM]">
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Grid,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
@@ -34,10 +33,7 @@ import {
|
||||
CreditCardOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
NumberOutlined,
|
||||
PlusOutlined,
|
||||
TagOutlined,
|
||||
UserOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
@@ -45,10 +41,15 @@ import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryDashboardDto,
|
||||
TreasuryPartyBalanceDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
|
||||
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
|
||||
|
||||
const kind = 'income' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const dashboard = ref<TreasuryDashboardDto | null>(null);
|
||||
@@ -70,9 +72,6 @@ const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const formTab = ref<'main' | 'extra'>('main');
|
||||
const categoryModalOpen = ref(false);
|
||||
const newCategoryName = ref('');
|
||||
const categorySaving = ref(false);
|
||||
const mobileFormRef = ref<FormInstance>();
|
||||
|
||||
function emptyForm() {
|
||||
@@ -80,17 +79,18 @@ function emptyForm() {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: undefined as string | undefined,
|
||||
partyName: '',
|
||||
partyKind: undefined as FinancialPartyKind | undefined,
|
||||
partyId: undefined as string | undefined,
|
||||
accountId: undefined as string | undefined,
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
bookedAt: formatLocalDateInput(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
const form = ref(emptyForm());
|
||||
const settleMirror = useSettleDefault(form);
|
||||
|
||||
const settleLabel = 'مبلغ دریافتشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc, dash] = await Promise.all([
|
||||
const [docs, cats, acc, dash, bals] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
balances.value = bals.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
if (dash) dashboard.value = dash.data;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
settleMirror.reset();
|
||||
form.value = emptyForm();
|
||||
formTab.value = 'main';
|
||||
error.value = '';
|
||||
@@ -167,22 +184,34 @@ async function createDoc() {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||
error.value = 'طرف حساب را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||
error.value = 'برای سایر، مبلغ دریافتشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
partyKind: form.value.partyKind,
|
||||
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
|
||||
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
|
||||
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
bookedAt: form.value.bookedAt || undefined,
|
||||
});
|
||||
resetForm();
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -211,30 +240,6 @@ async function voidDoc(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategoryQuick() {
|
||||
const name = newCategoryName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return;
|
||||
}
|
||||
categorySaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
|
||||
name,
|
||||
kind,
|
||||
});
|
||||
await load();
|
||||
form.value.categoryId = data.id;
|
||||
newCategoryName.value = '';
|
||||
categoryModalOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
categorySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
@@ -358,10 +363,8 @@ onMounted(() => {
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="مشتری / طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
<a-form-item label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
@@ -393,21 +396,16 @@ onMounted(() => {
|
||||
<span>دستهبندی درآمد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-flex :gap="8">
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="flex: 1; min-width: 0"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" ghost @click="categoryModalOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
@@ -426,7 +424,12 @@ onMounted(() => {
|
||||
name="settleAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
|
||||
<MoneyInput
|
||||
v-model="form.settleAmount"
|
||||
:decimals="0"
|
||||
:disabled="form.partyKind === 'other'"
|
||||
placeholder="۰"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -493,12 +496,6 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-show="formTab === 'extra'">
|
||||
<a-form-item label="شماره پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-textarea
|
||||
v-model:value="form.notes"
|
||||
@@ -511,13 +508,15 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -540,11 +539,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -614,7 +609,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -632,8 +627,8 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
<a-form-item label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -674,11 +669,6 @@ onMounted(() => {
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
@@ -688,6 +678,8 @@ onMounted(() => {
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
@@ -696,7 +688,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -722,11 +714,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -735,23 +723,5 @@ onMounted(() => {
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
v-model:open="categoryModalOpen"
|
||||
title="دسته جدید"
|
||||
ok-text="افزودن"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="categorySaving"
|
||||
@ok="createCategoryQuick"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام دسته" required>
|
||||
<a-input
|
||||
v-model:value="newCategoryName"
|
||||
placeholder="مثلاً: فروش خدمات"
|
||||
@press-enter="createCategoryQuick"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
@@ -9,16 +10,21 @@ import {
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Tabs as ATabs,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@/icons/ant-bridge';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ATabPane = ATabs.TabPane;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
|
||||
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const reportTab = ref('periods');
|
||||
const error = ref('');
|
||||
const balanceError = ref('');
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
@@ -81,6 +97,50 @@ async function load() {
|
||||
receivablesByParty.value = data.receivablesByParty;
|
||||
}
|
||||
|
||||
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
|
||||
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
|
||||
}
|
||||
|
||||
function exportParties(
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
title: string,
|
||||
settledLabel: string,
|
||||
filename: string,
|
||||
) {
|
||||
const body = rows.map((row) => [
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
Number(row.amount),
|
||||
Number(row.settledAmount),
|
||||
Number(row.remaining),
|
||||
]);
|
||||
const totals = rows.reduce(
|
||||
(sum, row) => ({
|
||||
amount: sum.amount + Number(row.amount || 0),
|
||||
settled: sum.settled + Number(row.settledAmount || 0),
|
||||
remaining: sum.remaining + Number(row.remaining || 0),
|
||||
}),
|
||||
{ amount: 0, settled: 0, remaining: 0 },
|
||||
);
|
||||
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
|
||||
...body,
|
||||
]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, title);
|
||||
XLSX.writeFile(book, filename);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
const [income, expense] = await Promise.all([
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
|
||||
]);
|
||||
debtors.value = income.data;
|
||||
creditors.value = expense.data;
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
@@ -95,6 +155,14 @@ onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گزارش بارگذاری نشد.';
|
||||
});
|
||||
void loadBalances().catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
|
||||
debtors.value = [];
|
||||
creditors.value = [];
|
||||
return;
|
||||
}
|
||||
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -108,9 +176,12 @@ onMounted(() => {
|
||||
</a-flex>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مقایسه دورهها</a-divider>
|
||||
<a-tabs v-model:activeKey="reportTab">
|
||||
<a-tab-pane key="periods" tab="مقایسه دورهها">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-table
|
||||
v-if="metricRows.length"
|
||||
:columns="metricColumns"
|
||||
@@ -168,6 +239,39 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="debtors" tab="بدهکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافتشده', 'debtors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافتشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="creditors" tab="بستانکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداختشده', 'creditors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداختشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import type {
|
||||
FinancialDocumentDto,
|
||||
FinancialInstallmentDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
function daysFromNow(days: number) {
|
||||
const d = new Date();
|
||||
d.setHours(12, 0, 0, 0);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function doc(
|
||||
partial: Pick<
|
||||
FinancialDocumentDto,
|
||||
'id' | 'kind' | 'title' | 'amount' | 'settledAmount' | 'remaining' | 'status' | 'categoryName' | 'partyName'
|
||||
> &
|
||||
Partial<FinancialDocumentDto>,
|
||||
): FinancialDocumentDto {
|
||||
return {
|
||||
bookedAt: daysFromNow(-3),
|
||||
dueAt: daysFromNow(10),
|
||||
categoryId: null,
|
||||
personId: null,
|
||||
accountId: null,
|
||||
accountName: 'صندوق اصلی',
|
||||
method: 'transfer',
|
||||
reference: null,
|
||||
notes: null,
|
||||
createdAt: daysFromNow(-2),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function installment(
|
||||
partial: FinancialInstallmentDto,
|
||||
): FinancialInstallmentDto {
|
||||
return partial;
|
||||
}
|
||||
|
||||
/** دیتای نمایشی برای پیشنمایش داشبورد وقتی API خالی است */
|
||||
export const TREASURY_DASHBOARD_MOCK: TreasuryDashboardDto = {
|
||||
bookedIncome: '185000000',
|
||||
actualReceipts: '142500000',
|
||||
bookedExpense: '98000000',
|
||||
actualPayments: '87500000',
|
||||
operatingProfit: '87000000',
|
||||
receivables: '42500000',
|
||||
payables: '18500000',
|
||||
totalBalance: '256000000',
|
||||
upcomingInstallments: [
|
||||
installment({
|
||||
id: 'mock-inst-1',
|
||||
position: 2,
|
||||
dueDate: daysFromNow(-2),
|
||||
amount: '12000000',
|
||||
paidAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'overdue',
|
||||
}),
|
||||
installment({
|
||||
id: 'mock-inst-2',
|
||||
position: 3,
|
||||
dueDate: daysFromNow(5),
|
||||
amount: '12000000',
|
||||
paidAmount: '4000000',
|
||||
remaining: '8000000',
|
||||
status: 'partial',
|
||||
}),
|
||||
installment({
|
||||
id: 'mock-inst-3',
|
||||
position: 4,
|
||||
dueDate: daysFromNow(35),
|
||||
amount: '12000000',
|
||||
paidAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'open',
|
||||
}),
|
||||
],
|
||||
upcomingPayables: [
|
||||
doc({
|
||||
id: 'mock-pay-1',
|
||||
kind: 'expense',
|
||||
title: 'اجاره دفتر — مهر',
|
||||
amount: '45000000',
|
||||
settledAmount: '30000000',
|
||||
remaining: '15000000',
|
||||
status: 'partial',
|
||||
categoryName: 'اجاره',
|
||||
partyName: 'موجر ساختمان',
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-pay-2',
|
||||
kind: 'expense',
|
||||
title: 'بیمه تکمیلی کارکنان',
|
||||
amount: '8500000',
|
||||
settledAmount: '5000000',
|
||||
remaining: '3500000',
|
||||
status: 'partial',
|
||||
categoryName: 'بیمه',
|
||||
partyName: 'بیمه ایران',
|
||||
}),
|
||||
],
|
||||
recent: [
|
||||
doc({
|
||||
id: 'mock-doc-1',
|
||||
kind: 'income',
|
||||
title: 'پیشپرداخت پروژه آلفا',
|
||||
amount: '75000000',
|
||||
settledAmount: '75000000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'فروش خدمات',
|
||||
partyName: 'شرکت آریا',
|
||||
bookedAt: daysFromNow(-1),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-2',
|
||||
kind: 'expense',
|
||||
title: 'خرید تجهیزات شبکه',
|
||||
amount: '18500000',
|
||||
settledAmount: '18500000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'تجهیزات',
|
||||
partyName: 'فروشگاه سختافزار',
|
||||
bookedAt: daysFromNow(-2),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-3',
|
||||
kind: 'income',
|
||||
title: 'قسط قرارداد بتا',
|
||||
amount: '12000000',
|
||||
settledAmount: '0',
|
||||
remaining: '12000000',
|
||||
status: 'open',
|
||||
categoryName: 'فروش خدمات',
|
||||
partyName: 'گروه سپهر',
|
||||
bookedAt: daysFromNow(-4),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-4',
|
||||
kind: 'expense',
|
||||
title: 'حقوق مهر — تیم فروش',
|
||||
amount: '42000000',
|
||||
settledAmount: '42000000',
|
||||
remaining: '0',
|
||||
status: 'settled',
|
||||
categoryName: 'حقوق',
|
||||
partyName: null,
|
||||
bookedAt: daysFromNow(-5),
|
||||
}),
|
||||
doc({
|
||||
id: 'mock-doc-5',
|
||||
kind: 'income',
|
||||
title: 'فروش پکیج پشتیبانی',
|
||||
amount: '28000000',
|
||||
settledAmount: '10000000',
|
||||
remaining: '18000000',
|
||||
status: 'partial',
|
||||
categoryName: 'پشتیبانی',
|
||||
partyName: 'کارخانه نوین',
|
||||
bookedAt: daysFromNow(-6),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export function isTreasuryDashboardEmpty(data: TreasuryDashboardDto | null | undefined) {
|
||||
if (!data) return true;
|
||||
const keys = [
|
||||
data.bookedIncome,
|
||||
data.actualReceipts,
|
||||
data.bookedExpense,
|
||||
data.actualPayments,
|
||||
data.totalBalance,
|
||||
data.receivables,
|
||||
data.payables,
|
||||
] as const;
|
||||
const allZero = keys.every((v) => !Number(v));
|
||||
return (
|
||||
allZero &&
|
||||
data.recent.length === 0 &&
|
||||
data.upcomingInstallments.length === 0 &&
|
||||
data.upcomingPayables.length === 0
|
||||
);
|
||||
}
|
||||
+1025
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* تنها منبع نام و لوگوی محصول.
|
||||
* برای تغییر برند همین فایل را عوض کن؛ برای لوگو فایل
|
||||
* `apps/web/public/brand/logo.png` را جایگزین کن یا مسیر `logo` را اینجا تغییر بده.
|
||||
* `apps/web/public/brand/logo.svg` را جایگزین کن یا مسیر `logo` را اینجا تغییر بده.
|
||||
*/
|
||||
export const brand = {
|
||||
id: 'montact',
|
||||
@@ -10,7 +10,10 @@ export const brand = {
|
||||
fullName: 'Montact CRM',
|
||||
company: 'Mentech',
|
||||
taglineFa: 'نرمافزار مدیریت ارتباط با مشتری',
|
||||
logo: '/brand/logo.png',
|
||||
/** لوگوی اصلی علامت (Asset 26) — همهٔ مصرفها از این مسیر */
|
||||
logo: '/brand/logo.svg',
|
||||
/** لوگو افقی با متن (Asset 24) — فقط سایدبار */
|
||||
logoWordmark: '/brand/montact-wordmark.svg',
|
||||
askPlaceholderFa: 'از مُنتَکت بپرس...',
|
||||
askLabelFa: 'از مُنتَکت بپرس',
|
||||
memoryEn: 'Montact Memory',
|
||||
|
||||
@@ -843,7 +843,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
{ key: "treasury_recurring", label: "مالی — تکرارشونده" },
|
||||
{ key: "treasury_reports", label: "مالی — گزارش" },
|
||||
{ key: "treasury_audit", label: "مالی — سوابق" },
|
||||
{ key: "treasury_categories", label: "مالی — دستهبندیها" },
|
||||
{ key: "treasury_categories", label: "مالی — دستهبندی" },
|
||||
{ key: "behavior", label: "شخصیتشناسی و دستیار فروش" },
|
||||
];
|
||||
|
||||
@@ -4329,6 +4329,11 @@ export interface TahesabConnectionDto {
|
||||
lastHealthAt: string | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
/** ساعتهای ۰ تا ۲۳ به وقت تهران. خالی یعنی بروزرسانی خودکار خاموش است. */
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: string | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -4343,7 +4348,11 @@ export interface CreateTahesabConnectionRequest {
|
||||
allowInsecureTls?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateTahesabConnectionRequest = Partial<CreateTahesabConnectionRequest>;
|
||||
export interface UpdateTahesabConnectionRequest
|
||||
extends Partial<CreateTahesabConnectionRequest> {
|
||||
/** آرایهٔ خالی بروزرسانی خودکار را خاموش میکند. */
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export interface PersonAccountingLinkDto {
|
||||
id: string;
|
||||
@@ -6303,6 +6312,8 @@ export interface RoutineDto {
|
||||
entityLink: RoutineEntityLink;
|
||||
reminderMinutesBefore: number | null;
|
||||
isActive: boolean;
|
||||
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش میکند */
|
||||
createdById: string | null;
|
||||
recurrenceLabel: string;
|
||||
/** درصد اجرا در ۳۰ روز اخیر */
|
||||
executionRate: number | null;
|
||||
@@ -6503,6 +6514,7 @@ export interface RoutineUserSettingsDto {
|
||||
export type FinancialAccountType = "bank" | "cash" | "petty";
|
||||
export type FinancialDocKind = "income" | "expense";
|
||||
export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
|
||||
export type FinancialPartyKind = "person" | "company" | "user" | "other";
|
||||
export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
|
||||
|
||||
export interface FinancialAccountDto {
|
||||
@@ -6541,7 +6553,10 @@ export interface FinancialDocumentDto {
|
||||
dueAt: string | null;
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
partyKind: FinancialPartyKind | null;
|
||||
personId: string | null;
|
||||
companyId: string | null;
|
||||
userId: string | null;
|
||||
partyName: string | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
@@ -6594,6 +6609,26 @@ export interface FinancialReceivableDto {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TreasuryPartyChoiceDto {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TreasuryPartySearchDto {
|
||||
people: TreasuryPartyChoiceDto[];
|
||||
companies: TreasuryPartyChoiceDto[];
|
||||
users: TreasuryPartyChoiceDto[];
|
||||
}
|
||||
|
||||
export interface TreasuryPartyBalanceDto {
|
||||
key: string;
|
||||
partyKind: FinancialPartyKind | null;
|
||||
partyName: string;
|
||||
amount: string;
|
||||
settledAmount: string;
|
||||
remaining: string;
|
||||
}
|
||||
|
||||
export interface TreasuryDashboardDto {
|
||||
bookedIncome: string;
|
||||
actualReceipts: string;
|
||||
|
||||
Reference in New Issue
Block a user