14 Commits
Author SHA1 Message Date
MahdiandCursor 1e0b638a19 fix(web): tidy the notification panel with status icons
Keep cards neutral, show type through icon and color only, and hide the panel scrollbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:29:40 +03:30
Mahdi 3db9c1c0fe merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
2026-11-05 15:10:48 +03:30
MahdiandCursor 1ef7f91771 fix(contacts): keep one customer when Tahesab repeats a mobile
A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:34:41 +03:30
MahdiandCursor 94f0383c79 feat(tahesab): import and refresh customers at chosen hours
Each connection can set how many times a day and at which Tehran hours the server pulls Tahesab customers, including name updates for people already linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:31:27 +03:30
MahdiandCursor c976c0bcd6 fix(contacts): merge the same mobile stored on phone or mobile
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:27:05 +03:30
MahdiandCursor 57a0b379f6 fix(tahesab): store a matched mobile as 09xxxxxxxxx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:11:40 +03:30
MahdiandCursor 7f6d9a84d6 fix(contacts): treat Iranian mobile formats as one person
Tahesab import still replaces the name and keeps the previous name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:51:36 +03:30
MahdiandCursor 6abbf8e8fd fix(sms): keep the charge dialog fully on screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:52:35 +03:30
MahdiandCursor 39cb5c2548 feat(public-api): let external sites register a lead with their key
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:34:07 +03:30
MahdiandCursor 004d1dff93 feat(boards): ease dragging a card toward an off-screen spot
Scroll the column while a card is held near its edge and show a light gap where the card will land.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 20:05:50 +03:30
MahdiandCursor 78a7d6a68a feat(routines): let each person create a routine for themselves
Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 19:34:36 +03:30
MahdiandCursor 5b18749005 feat(treasury): list debtors and creditors on the finance report
Show open receivable and payable balances on separate report tabs, and include each list in an Excel export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 14:16:38 +03:30
MahdiandCursor 08a278e1c8 feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 13:11:09 +03:30
MahdiandCursor d71c2dbef4 feat(treasury): show who still owes on the finance pages
Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:36:55 +03:30
56 changed files with 2874 additions and 384 deletions
@@ -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;
+23
View File
@@ -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;
+2 -1
View File
@@ -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 {}
+367
View File
@@ -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());
}
+33
View File
@@ -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);
}
+18 -7
View File
@@ -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}`);
}
}
}
+10
View File
@@ -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 },
+16 -8
View File
@@ -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 {
+110 -18
View File
@@ -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 },
});
}
+13 -6
View File
@@ -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;
}
+3 -2
View File
@@ -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(
+2 -1
View File
@@ -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],
+1
View File
@@ -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,
+28 -13
View File
@@ -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 },
+38 -1
View File
@@ -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: {
+43 -2
View File
@@ -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"',
+2 -1
View File
@@ -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,
+311 -16
View File
@@ -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,
+8 -5
View File
@@ -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;
@@ -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;
},
};
}
+155 -26
View File
@@ -553,6 +553,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;
@@ -1058,7 +1093,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 +1106,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>
@@ -2498,13 +2545,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 +2561,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 {
+37
View File
@@ -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,
+2 -2
View File
@@ -369,8 +369,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.',
treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
},
},
],
+2 -2
View File
@@ -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 } });
+59 -13
View File
@@ -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"
+4 -2
View File
@@ -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">
+135 -1
View File
@@ -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,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>
@@ -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>
@@ -30,6 +30,9 @@ function doc(
notes: null,
createdAt: daysFromNow(-2),
...partial,
partyKind: partial.partyKind ?? null,
companyId: partial.companyId ?? null,
userId: partial.userId ?? null,
};
}
+37 -2
View File
@@ -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;