Compare commits
27
Commits
b11affbdfd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0998a1ec37 | ||
|
|
4979aa8b75 | ||
|
|
34d3689448 | ||
|
|
1e0b638a19 | ||
|
|
3db9c1c0fe | ||
|
|
6c6e32e099 | ||
|
|
1ef7f91771 | ||
|
|
94f0383c79 | ||
|
|
c976c0bcd6 | ||
|
|
57a0b379f6 | ||
|
|
7f6d9a84d6 | ||
|
|
6abbf8e8fd | ||
|
|
39cb5c2548 | ||
|
|
004d1dff93 | ||
|
|
78a7d6a68a | ||
|
|
5b18749005 | ||
|
|
08a278e1c8 | ||
|
|
d71c2dbef4 | ||
|
|
41f310f3a2 | ||
|
|
754cc43c98 | ||
|
|
a319085dca | ||
|
|
cb3a488b21 | ||
|
|
1ccbefa7a3 | ||
|
|
14f4e9d7d8 | ||
|
|
52edccf74e | ||
|
|
bffe174162 | ||
|
|
35d74d33ed |
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
|
||||
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
|
||||
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
|
||||
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
|
||||
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
|
||||
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
|
||||
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "Activity" ADD COLUMN "boardPosition" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Activity_stageId_boardPosition_idx" ON "Activity"("stageId", "boardPosition");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryAccountIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "OrgRole" ADD COLUMN "treasuryOwnAccounts" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Membership" ADD COLUMN "treasuryAccessOverride" JSONB;
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT;
|
||||
ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT;
|
||||
|
||||
CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId");
|
||||
CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId");
|
||||
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "DataFix" (
|
||||
"key" TEXT NOT NULL,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataFix_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "AccountingConnection"
|
||||
ADD COLUMN "customerSyncHours" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "customerSyncLastSlot" TEXT,
|
||||
ADD COLUMN "customerSyncLastRunAt" TIMESTAMP(3),
|
||||
ADD COLUMN "customerSyncLastSummary" TEXT,
|
||||
ADD COLUMN "customerSyncLastError" TEXT;
|
||||
@@ -295,6 +295,7 @@ model User {
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||
@@ -353,6 +354,10 @@ model OrgRole {
|
||||
permissions String[] @default([])
|
||||
/// عمق داده بهازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
|
||||
dataAccessPolicy Json @default("{}")
|
||||
/// حسابهای مالی اضافی این نقش؛ همراه با حسابهایی که خودش مسئول است
|
||||
treasuryAccountIds String[] @default([])
|
||||
/// اعضای نقش، حسابهایی را ببینند که نام خودشان مسئول آن است
|
||||
treasuryOwnAccounts Boolean @default(false)
|
||||
/// owner | admin | sales | support — نقشهای سیستمی قابل حذف نیستند
|
||||
systemKey String?
|
||||
/// نقش والد برای سلسلهمراتب زیرنقشها
|
||||
@@ -387,6 +392,8 @@ model Membership {
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
|
||||
dataAccessOverride Json @default("{}")
|
||||
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخشها و حسابهای مالی همین کاربر
|
||||
treasuryAccessOverride Json?
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
@@ -518,6 +525,7 @@ model Company {
|
||||
customFields Json @default("{}")
|
||||
groupLinks CompanyGroupLink[]
|
||||
people Person[]
|
||||
financialDocuments FinancialDocument[]
|
||||
deals Deal[]
|
||||
activities Activity[]
|
||||
smartNotes Note[]
|
||||
@@ -896,6 +904,8 @@ model Activity {
|
||||
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهمنوع با status → اولین مرحلهٔ همنوع
|
||||
stageId String?
|
||||
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
|
||||
/// ترتیب کارت داخل همان ستون کانبان
|
||||
boardPosition Int @default(0)
|
||||
/// نوع سفارشی؛ null → نوع اصلیِ همان type
|
||||
typeDefId String?
|
||||
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
|
||||
@@ -911,6 +921,7 @@ model Activity {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([stageId])
|
||||
@@index([stageId, boardPosition])
|
||||
@@index([boardId])
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
@@ -1832,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[]
|
||||
@@ -2321,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)
|
||||
@@ -2341,6 +2365,8 @@ model FinancialDocument {
|
||||
@@index([organizationId, kind, bookedAt])
|
||||
@@index([organizationId, status])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||
@@ -3760,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;
|
||||
|
||||
@@ -428,7 +428,13 @@ export class ActivitiesController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveActivityStageDto,
|
||||
) {
|
||||
return this.activitiesService.moveToStage(user.organizationId, id, user, dto.stageId);
|
||||
return this.activitiesService.moveToStage(
|
||||
user.organizationId,
|
||||
id,
|
||||
user,
|
||||
dto.stageId,
|
||||
dto.position,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ownerWhere,
|
||||
} from '../common/record-scope';
|
||||
import { assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
@@ -329,9 +330,7 @@ export class ActivitiesService {
|
||||
where,
|
||||
include,
|
||||
// روی پروندهٔ لید، تازهترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
|
||||
orderBy: entity?.leadId
|
||||
? [{ createdAt: 'desc' }]
|
||||
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
skip: (page - 1) * take,
|
||||
take,
|
||||
}),
|
||||
@@ -385,7 +384,7 @@ export class ActivitiesService {
|
||||
this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: this.listOrder(entity, stageScope),
|
||||
take: cap,
|
||||
}),
|
||||
this.prisma.activity.count({ where }),
|
||||
@@ -490,11 +489,46 @@ export class ActivitiesService {
|
||||
return { counts, total };
|
||||
}
|
||||
|
||||
private listOrder(
|
||||
entity?: { leadId?: string },
|
||||
stageScope?: StageScope,
|
||||
): Prisma.ActivityOrderByWithRelationInput[] {
|
||||
if (entity?.leadId) return [{ createdAt: 'desc' }];
|
||||
if (stageScope && stageScope.stageId !== 'all') {
|
||||
return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
return [{ dueAt: 'asc' }, { createdAt: 'desc' }];
|
||||
}
|
||||
|
||||
private async reorderStage(
|
||||
organizationId: string,
|
||||
stageId: string,
|
||||
activityId: string,
|
||||
index: number,
|
||||
) {
|
||||
const where = await this.stages.whereForStage(organizationId, stageId);
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
const placed = placeId(currentIds, activityId, index);
|
||||
if (sameIds(currentIds, placed)) return;
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placed,
|
||||
(id, boardPosition) =>
|
||||
this.prisma.activity.update({ where: { id }, data: { boardPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToStage(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: ActivityActor,
|
||||
stageId: string,
|
||||
position?: number,
|
||||
): Promise<ActivityDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
const stage = await this.stages.get(organizationId, stageId);
|
||||
@@ -519,6 +553,7 @@ export class ActivitiesService {
|
||||
},
|
||||
include,
|
||||
});
|
||||
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
|
||||
if (stage.kind === 'done' && existing?.status !== 'done') {
|
||||
await this.fireDone(organizationId, row);
|
||||
}
|
||||
|
||||
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
|
||||
export class MoveActivityStageDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی میشود */
|
||||
@@ -316,7 +322,7 @@ export class CreateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -416,7 +422,7 @@ export class UpdateActivityDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type PositionUpdate = Prisma.PrismaPromise<unknown>;
|
||||
|
||||
/** کارت را در ایندکس خواستهشده میگذارد. ایندکس نسبت به فهرست بدون خود کارت است. */
|
||||
export function placeId(ids: string[], movingId: string, index: number): string[] {
|
||||
const next = ids.filter((id) => id !== movingId);
|
||||
const at = Math.max(0, Math.min(Math.trunc(index), next.length));
|
||||
next.splice(at, 0, movingId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function sameIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
/** ترتیب ستون را ۰ تا n ذخیره میکند. */
|
||||
export async function writePositions(
|
||||
transaction: (ops: PositionUpdate[]) => Promise<unknown>,
|
||||
ids: string[],
|
||||
update: (id: string, position: number) => PositionUpdate,
|
||||
) {
|
||||
const size = 60;
|
||||
for (let start = 0; start < ids.length; start += size) {
|
||||
const slice = ids.slice(start, start + size);
|
||||
await transaction(slice.map((id, offset) => update(id, start + offset)));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { SameMobileMergeService } from './same-mobile-merge.service';
|
||||
import { BehaviorModule } from '../behavior/behavior.module';
|
||||
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
|
||||
CustomerGroupsModule,
|
||||
],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
|
||||
|
||||
const personSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
gender: true,
|
||||
showGenderInName: true,
|
||||
previousFirstName: true,
|
||||
previousLastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
position: true,
|
||||
website: true,
|
||||
instagram: true,
|
||||
birthDate: true,
|
||||
referrer: true,
|
||||
addresses: true,
|
||||
notes: true,
|
||||
tags: true,
|
||||
companyId: true,
|
||||
customFields: true,
|
||||
importSource: true,
|
||||
sourceRef: true,
|
||||
previousImportSource: true,
|
||||
previousSourceRef: true,
|
||||
loyaltyPoints: true,
|
||||
discLabel: true,
|
||||
discReliable: true,
|
||||
discAssessedAt: true,
|
||||
} satisfies Prisma.PersonSelect;
|
||||
|
||||
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
|
||||
|
||||
function blank(value: string | null | undefined) {
|
||||
return !value?.trim();
|
||||
}
|
||||
|
||||
function asStrings(value: Prisma.JsonValue): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return { ...(value as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
|
||||
const notes = [keeper.notes?.trim() || ''];
|
||||
let email = keeper.email;
|
||||
let phone = storedPhoneNumber(keeper.phone);
|
||||
let position = keeper.position;
|
||||
let website = keeper.website;
|
||||
let instagram = keeper.instagram;
|
||||
let birthDate = keeper.birthDate;
|
||||
let referrer = keeper.referrer;
|
||||
let companyId = keeper.companyId;
|
||||
let gender = keeper.gender;
|
||||
let showGenderInName = keeper.showGenderInName;
|
||||
let previousFirstName = keeper.previousFirstName;
|
||||
let previousLastName = keeper.previousLastName;
|
||||
let previousImportSource = keeper.previousImportSource;
|
||||
let previousSourceRef = keeper.previousSourceRef;
|
||||
let discLabel = keeper.discLabel;
|
||||
let discReliable = keeper.discReliable;
|
||||
let discAssessedAt = keeper.discAssessedAt;
|
||||
let loyaltyPoints = keeper.loyaltyPoints;
|
||||
const addresses = asStrings(keeper.addresses);
|
||||
const tags = new Set(keeper.tags);
|
||||
const customFields = asObject(keeper.customFields);
|
||||
|
||||
for (const extra of extras) {
|
||||
const name = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
notes.push(
|
||||
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
|
||||
);
|
||||
if (blank(email) && extra.email) email = extra.email;
|
||||
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
|
||||
if (blank(position) && extra.position) position = extra.position;
|
||||
if (blank(website) && extra.website) website = extra.website;
|
||||
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
|
||||
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
|
||||
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
|
||||
if (!companyId && extra.companyId) companyId = extra.companyId;
|
||||
if (gender === 'none' && extra.gender !== 'none') {
|
||||
gender = extra.gender;
|
||||
showGenderInName = extra.showGenderInName;
|
||||
}
|
||||
if (blank(previousFirstName) && extra.previousFirstName) {
|
||||
previousFirstName = extra.previousFirstName;
|
||||
}
|
||||
if (blank(previousLastName) && extra.previousLastName) {
|
||||
previousLastName = extra.previousLastName;
|
||||
}
|
||||
if (
|
||||
blank(previousImportSource) &&
|
||||
extra.importSource &&
|
||||
extra.importSource !== keeper.importSource
|
||||
) {
|
||||
previousImportSource = extra.importSource;
|
||||
previousSourceRef = extra.sourceRef;
|
||||
}
|
||||
if (
|
||||
keeper.importSource === 'tahesab' &&
|
||||
blank(previousFirstName) &&
|
||||
blank(previousLastName)
|
||||
) {
|
||||
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
|
||||
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
|
||||
if (extraName && extraName !== keeperName) {
|
||||
previousFirstName = extra.firstName || null;
|
||||
previousLastName = extra.lastName || null;
|
||||
}
|
||||
}
|
||||
if (!discLabel && extra.discLabel) {
|
||||
discLabel = extra.discLabel;
|
||||
discReliable = extra.discReliable;
|
||||
discAssessedAt = extra.discAssessedAt;
|
||||
}
|
||||
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
|
||||
for (const address of asStrings(extra.addresses)) {
|
||||
if (!addresses.includes(address)) addresses.push(address);
|
||||
}
|
||||
for (const tag of extra.tags) tags.add(tag);
|
||||
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
|
||||
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mobile: canon,
|
||||
email,
|
||||
phone,
|
||||
position,
|
||||
website,
|
||||
instagram,
|
||||
birthDate,
|
||||
referrer,
|
||||
companyId,
|
||||
gender,
|
||||
showGenderInName,
|
||||
previousFirstName,
|
||||
previousLastName,
|
||||
previousImportSource,
|
||||
previousSourceRef,
|
||||
discLabel,
|
||||
discReliable,
|
||||
discAssessedAt,
|
||||
loyaltyPoints,
|
||||
addresses,
|
||||
tags: [...tags],
|
||||
customFields: customFields as Prisma.InputJsonValue,
|
||||
notes: notes.filter(Boolean).join('\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
|
||||
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND column_name = 'personId'
|
||||
`;
|
||||
return rows
|
||||
.map((row) => row.table_name)
|
||||
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
|
||||
}
|
||||
|
||||
async function moveLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
tables: string[],
|
||||
keeperId: string,
|
||||
dupId: string,
|
||||
) {
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonGroupLink" AS d
|
||||
USING "PersonGroupLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."groupId" = k."groupId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PersonAccountingLink" AS d
|
||||
USING "PersonAccountingLink" AS k
|
||||
WHERE d."personId" = ${dupId}
|
||||
AND k."personId" = ${keeperId}
|
||||
AND d."connectionId" = k."connectionId"
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "PartyOpeningBalance"
|
||||
WHERE "personId" = ${dupId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
|
||||
)
|
||||
`;
|
||||
|
||||
for (const table of tables) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
|
||||
keeperId,
|
||||
dupId,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "MontifyLink"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "Notification"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "GamificationTransaction"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
await tx.$executeRaw`
|
||||
UPDATE "FinancialAuditLog"
|
||||
SET "entityId" = ${keeperId}
|
||||
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function mergeSameMobilePeople(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
apply: boolean,
|
||||
) {
|
||||
const people = await prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
select: personSelect,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const groups = new Map<string, PersonRow[]>();
|
||||
for (const person of people) {
|
||||
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
if (!key) continue;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(person);
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
|
||||
const preview = duplicateGroups.map(([mobile, list]) => ({
|
||||
mobile,
|
||||
people: list.map((person) => ({
|
||||
id: person.id,
|
||||
stored: person.mobile,
|
||||
name: `${person.firstName} ${person.lastName}`.trim(),
|
||||
})),
|
||||
}));
|
||||
|
||||
if (!apply) {
|
||||
const needsRewrite = people.filter((person) => {
|
||||
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
|
||||
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
|
||||
}).length;
|
||||
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
|
||||
}
|
||||
|
||||
const tables = await personIdTables(prisma);
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [canon, list] of groups) {
|
||||
const ordered = [...list].sort((a, b) => {
|
||||
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
|
||||
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
|
||||
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
|
||||
});
|
||||
const [keeper, ...extras] = ordered;
|
||||
if (!keeper) continue;
|
||||
if (!extras.length) {
|
||||
const phoneCanon = canonicalIranMobile(keeper.phone);
|
||||
const data: Prisma.PersonUpdateInput = {};
|
||||
if (keeper.mobile !== canon) data.mobile = canon;
|
||||
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
|
||||
if (Object.keys(data).length) {
|
||||
await prisma.person.update({ where: { id: keeper.id }, data });
|
||||
rewritten += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.person.update({
|
||||
where: { id: keeper.id },
|
||||
data: mergedFields(keeper, extras, canon),
|
||||
});
|
||||
for (const extra of extras) {
|
||||
await moveLinks(tx, tables, keeper.id, extra.id);
|
||||
await tx.person.delete({ where: { id: extra.id } });
|
||||
merged += 1;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(
|
||||
`merge mobile ${canon} failed`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
|
||||
}
|
||||
|
||||
function loadEnv(file: string) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
|
||||
if (!match || process.env[match[1]]) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[match[1]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
|
||||
if (direct) {
|
||||
loadEnv(resolve(__dirname, '../../.env'));
|
||||
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
|
||||
const apply = process.argv.includes('--apply');
|
||||
if (!orgId) {
|
||||
console.error('org id required: --org=<id>');
|
||||
process.exit(1);
|
||||
}
|
||||
const prisma = new PrismaClient();
|
||||
mergeSameMobilePeople(prisma, orgId, apply)
|
||||
.then((result) => {
|
||||
console.log(JSON.stringify({
|
||||
apply,
|
||||
merged: result.merged,
|
||||
rewritten: result.rewritten,
|
||||
needsRewrite: result.needsRewrite,
|
||||
groupCount: result.groups.length,
|
||||
groups: result.groups,
|
||||
}, null, 2));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
|
||||
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شمارهاند. */
|
||||
export function canonicalIranMobile(value?: string | null): string | null {
|
||||
const n = normalizePhone(value);
|
||||
if (!n) return null;
|
||||
let d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** شکلهایی که ممکن است همین موبایل با آنها ذخیره شده باشد. */
|
||||
export function iranMobileLookupKeys(value?: string | null): string[] {
|
||||
const canon = canonicalIranMobile(value);
|
||||
if (!canon) return [];
|
||||
const core = canon.slice(1);
|
||||
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
|
||||
}
|
||||
|
||||
/** برای تطبیق: اگر موبایل ایران است همه شکلها، وگرنه همان متن نرمالشده. */
|
||||
export function phoneIdentityKeys(value?: string | null): string[] {
|
||||
const keys = iranMobileLookupKeys(value);
|
||||
if (keys.length) return keys;
|
||||
const n = normalizePhone(value);
|
||||
return n ? [n] : [];
|
||||
}
|
||||
|
||||
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شمارهها فقط تمیز میشوند. */
|
||||
export function storedPhoneNumber(value?: string | null): string | null {
|
||||
return canonicalIranMobile(value) ?? normalizePhone(value);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from './normalize';
|
||||
import {
|
||||
asAddresses,
|
||||
asCustomFields,
|
||||
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
|
||||
input: { mobile?: string | null; email?: string | null; birthDate: Date },
|
||||
) {
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (input.mobile) or.push({ mobile: input.mobile });
|
||||
const mobileKeys = phoneIdentityKeys(input.mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (input.email) or.push({ email: input.email });
|
||||
if (!or.length) return;
|
||||
await this.prisma.person.updateMany({
|
||||
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
|
||||
importSource: normalizePersonImportSource(dto.importSource),
|
||||
sourceRef: emptyToNull(dto.sourceRef),
|
||||
email: normalizeEmail(dto.email),
|
||||
phone: normalizePhone(dto.phone),
|
||||
mobile: normalizePhone(dto.mobile),
|
||||
phone: storedPhoneNumber(dto.phone),
|
||||
mobile: storedPhoneNumber(dto.mobile),
|
||||
position: emptyToNull(dto.position),
|
||||
website: emptyToNull(dto.website),
|
||||
instagram: emptyToNull(dto.instagram),
|
||||
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
|
||||
}
|
||||
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
|
||||
if ('email' in dto) data.email = normalizeEmail(dto.email);
|
||||
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
|
||||
if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
|
||||
if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
|
||||
if ('position' in dto) data.position = emptyToNull(dto.position);
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
|
||||
@@ -641,10 +649,13 @@ export class PeopleService implements OnModuleInit {
|
||||
) {
|
||||
const email = typeof data.email === 'string' ? data.email : null;
|
||||
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
if (!or.length) return;
|
||||
|
||||
const clash = await this.prisma.person.findFirst({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { mergeSameMobilePeople } from './merge-same-mobile';
|
||||
|
||||
const FIX_KEY = 'person-mobile-canonical-v3';
|
||||
|
||||
@Injectable()
|
||||
export class SameMobileMergeService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SameMobileMergeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
void this.run();
|
||||
}
|
||||
|
||||
private async run() {
|
||||
try {
|
||||
const ready = await this.prisma.$queryRaw<{ ok: number }[]>`
|
||||
SELECT 1 AS ok
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND lower(table_name) = 'person'
|
||||
AND lower(column_name) = 'gender'
|
||||
`;
|
||||
if (!ready.length) {
|
||||
this.logger.warn('یکسانسازی موبایل رد شد: ستون gender هنوز نیست');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS "DataFix" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"doneAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`;
|
||||
|
||||
const done = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (done.length) return;
|
||||
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_lock(86051205)`;
|
||||
try {
|
||||
const again = await this.prisma.$queryRaw<{ key: string }[]>`
|
||||
SELECT "key" FROM "DataFix" WHERE "key" = ${FIX_KEY}
|
||||
`;
|
||||
if (again.length) return;
|
||||
|
||||
const orgs = await this.prisma.organization.findMany({ select: { id: true } });
|
||||
let merged = 0;
|
||||
let rewritten = 0;
|
||||
let failed = 0;
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const result = await mergeSameMobilePeople(this.prisma, org.id, true);
|
||||
merged += result.merged;
|
||||
rewritten += result.rewritten;
|
||||
failed += result.failed;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
this.logger.error(
|
||||
`یکسانسازی موبایل برای محیط ${org.id} انجام نشد`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failed) return;
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT INTO "DataFix" ("key") VALUES (${FIX_KEY})
|
||||
ON CONFLICT ("key") DO NOTHING
|
||||
`;
|
||||
this.logger.log(`شمارههای تکراری یکی شد: ${merged} ادغام، ${rewritten} بازنویسی`);
|
||||
} finally {
|
||||
await this.prisma.$executeRaw`SELECT pg_advisory_unlock(86051205)`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'یکسانسازی موبایل مشتریها انجام نشد',
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { parseBirthDate } from '../../common/excel';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { ownerWhere, type Actor } from '../../common/record-scope';
|
||||
import { MontifyConnectionsService } from './connections.service';
|
||||
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
function mobileVariants(mobile: string): string[] {
|
||||
const digits = mobile.replace(/\D/g, '');
|
||||
let core = digits;
|
||||
if (core.startsWith('0098')) core = core.slice(4);
|
||||
else if (core.startsWith('98') && core.length === 12) core = core.slice(2);
|
||||
else if (core.startsWith('0')) core = core.slice(1);
|
||||
if (!core) return [mobile];
|
||||
return [...new Set([mobile, core, `0${core}`, `98${core}`, `+98${core}`])];
|
||||
return phoneIdentityKeys(mobile);
|
||||
}
|
||||
|
||||
function formatAddress(a: MontifyAddress): string | null {
|
||||
@@ -417,7 +415,7 @@ export class MontifySyncService {
|
||||
}> {
|
||||
const externalId = String(c.id);
|
||||
const { firstName, lastName } = splitName(c);
|
||||
const mobile = normalizePhone(c.phone ?? null);
|
||||
const mobile = storedPhoneNumber(c.phone ?? null);
|
||||
const email = normalizeEmail(c.email ?? null);
|
||||
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
|
||||
const profile: CustomerProfile = {
|
||||
|
||||
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: Date | null;
|
||||
lastHealthOk: boolean | null;
|
||||
lastError: string | null;
|
||||
customerSyncHours: number[];
|
||||
customerSyncLastRunAt: Date | null;
|
||||
customerSyncLastSummary: string | null;
|
||||
customerSyncLastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
|
||||
lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
|
||||
lastHealthOk: row.lastHealthOk,
|
||||
lastError: row.lastError,
|
||||
customerSyncHours: [...row.customerSyncHours].sort((a, b) => a - b),
|
||||
customerSyncLastRunAt: row.customerSyncLastRunAt?.toISOString() ?? null,
|
||||
customerSyncLastSummary: row.customerSyncLastSummary,
|
||||
customerSyncLastError: row.customerSyncLastError,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
|
||||
|
||||
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id);
|
||||
|
||||
let customerSyncHours: number[] | undefined;
|
||||
if (dto.customerSyncHours !== undefined) {
|
||||
const hours = dto.customerSyncHours.map((hour) => Math.trunc(hour));
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
throw new BadRequestException('ساعتهای بروزرسانی نباید تکراری باشند.');
|
||||
}
|
||||
customerSyncHours = [...hours].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
let enc = {
|
||||
ciphertext: row.tokenCiphertext,
|
||||
iv: row.tokenIv,
|
||||
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
|
||||
enabled: dto.enabled ?? row.enabled,
|
||||
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
|
||||
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
|
||||
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(updated);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TahesabPersonLinkService } from './person-link.service';
|
||||
|
||||
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یکبار در هر ساعت. */
|
||||
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '';
|
||||
let hour = Number(read('hour'));
|
||||
if (!Number.isFinite(hour) || hour === 24) hour = 0;
|
||||
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
|
||||
return { hour, slot };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TahesabCustomerSyncScheduler {
|
||||
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly links: TahesabPersonLinkService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
|
||||
async tick() {
|
||||
if (process.env.NODE_ENV === 'test' || this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
const { hour, slot } = tehranSyncSlot();
|
||||
const connections = await this.prisma.accountingConnection.findMany({
|
||||
where: {
|
||||
provider: 'tahesab',
|
||||
enabled: true,
|
||||
customerSyncHours: { has: hour },
|
||||
},
|
||||
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
|
||||
});
|
||||
for (const connection of connections) {
|
||||
if (connection.customerSyncLastSlot === slot) continue;
|
||||
const claimed = await this.prisma.accountingConnection.updateMany({
|
||||
where: {
|
||||
id: connection.id,
|
||||
OR: [
|
||||
{ customerSyncLastSlot: null },
|
||||
{ customerSyncLastSlot: { not: slot } },
|
||||
],
|
||||
},
|
||||
data: { customerSyncLastSlot: slot },
|
||||
});
|
||||
if (claimed.count !== 1) continue;
|
||||
await this.run(connection.organizationId, connection.id, connection.label);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`بررسی زمانبندی مشتریان تهحساب ناموفق بود: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async run(organizationId: string, connectionId: string, label: string) {
|
||||
try {
|
||||
const result = await this.links.syncAllCustomers(organizationId, connectionId);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: result.summary,
|
||||
customerSyncLastError: result.error,
|
||||
},
|
||||
});
|
||||
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
|
||||
} catch (err) {
|
||||
const message = (err instanceof Error ? err.message : 'همگامسازی ناموفق بود').slice(0, 500);
|
||||
await this.prisma.accountingConnection.update({
|
||||
where: { id: connectionId },
|
||||
data: {
|
||||
customerSyncLastRunAt: new Date(),
|
||||
customerSyncLastSummary: null,
|
||||
customerSyncLastError: message,
|
||||
},
|
||||
});
|
||||
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowInsecureTls?: boolean;
|
||||
|
||||
/** ساعتهای ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(23, { each: true })
|
||||
customerSyncHours?: number[];
|
||||
}
|
||||
|
||||
export class ManualLinkDto {
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { carryPreviousSource } from '../../contacts/person-source';
|
||||
import { carryPreviousSource, namesDiffer } from '../../contacts/person-source';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { applyPersonGenderTitle } from '@mento/shared';
|
||||
import { TahesabConnectionsService } from './connections.service';
|
||||
import {
|
||||
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
|
||||
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
|
||||
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
|
||||
|
||||
/** موبایل تهحساب را به شکل 0912… ذخیره میکند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
|
||||
function canonicalMobileUpdate(
|
||||
incoming: string | null,
|
||||
current: { mobile: string | null; phone: string | null },
|
||||
): { mobile?: string; phone?: string } {
|
||||
const mobile =
|
||||
canonicalIranMobile(incoming) ??
|
||||
canonicalIranMobile(current.mobile) ??
|
||||
canonicalIranMobile(current.phone);
|
||||
if (!mobile) return {};
|
||||
const phone = canonicalIranMobile(current.phone);
|
||||
return phone ? { mobile, phone } : { mobile };
|
||||
}
|
||||
|
||||
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
|
||||
const LIST_PAGE_SIZE = 2000;
|
||||
const LIST_TIMEOUT_MS = 55_000;
|
||||
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
|
||||
organizationId: string,
|
||||
dto: ImportFromTahesabDto,
|
||||
prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
|
||||
options?: { skipJob?: boolean },
|
||||
) {
|
||||
const clash = await this.prisma.personAccountingLink.findFirst({
|
||||
where: {
|
||||
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
|
||||
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
|
||||
const firstName = named.firstName;
|
||||
const lastName = named.lastName;
|
||||
const mobile = tel || null;
|
||||
const mobile = storedPhoneNumber(tel);
|
||||
const sourceRef = String(dto.moshtariCode);
|
||||
|
||||
let personId = dto.personId;
|
||||
let preserveExistingLink = false;
|
||||
if (personId) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const prevFirst = existing.firstName;
|
||||
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...(mobile
|
||||
? {
|
||||
mobile: existing.mobile || mobile,
|
||||
phone: existing.phone || mobile,
|
||||
}
|
||||
: {}),
|
||||
...canonicalMobileUpdate(mobile, existing),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام تهحساب جایگزین.
|
||||
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته میشود؛
|
||||
// وگرنه upsert لینک، کد قبلی را جابهجا و آن مشتری را دوباره «واردنشده» میکند.
|
||||
const byMobile = mobile
|
||||
? await this.prisma.person.findFirst({
|
||||
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابهجا نمیشود
|
||||
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const matches = mobileKeys.length
|
||||
? await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
accountingLinks: { none: { connectionId: dto.connectionId } },
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
include: {
|
||||
accountingLinks: {
|
||||
where: { connectionId: dto.connectionId },
|
||||
select: { moshtariCode: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
: [];
|
||||
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
|
||||
if (byMobile) {
|
||||
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
|
||||
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
|
||||
const nameChanged =
|
||||
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
|
||||
`${firstName} ${lastName}`.trim();
|
||||
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
|
||||
: {}),
|
||||
...carryPreviousSource(byMobile, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef,
|
||||
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
|
||||
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
|
||||
...canonicalMobileUpdate(mobile, byMobile),
|
||||
},
|
||||
});
|
||||
personId = byMobile.id;
|
||||
@@ -669,6 +696,32 @@ export class TahesabPersonLinkService {
|
||||
}
|
||||
}
|
||||
|
||||
if (preserveExistingLink) {
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
const existing = await this.prisma.personAccountingLink.findFirst({
|
||||
where: { personId, connectionId: dto.connectionId },
|
||||
include: {
|
||||
connection: { select: { label: true, dbName: true, isDefault: true } },
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('لینک تهحساب این مشتری یافت نشد.');
|
||||
}
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { personId, link: this.linkDto(existing) };
|
||||
}
|
||||
|
||||
const link = await this.prisma.personAccountingLink.upsert({
|
||||
where: {
|
||||
personId_connectionId: {
|
||||
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
|
||||
|
||||
if (!options?.skipJob) {
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
}
|
||||
|
||||
/** نام، موبایل و گروه مشتریِ از قبل لینکشده را با ردیف تهحساب تازه میکند. */
|
||||
private async refreshLinkedCustomer(
|
||||
organizationId: string,
|
||||
connectionId: string,
|
||||
personId: string,
|
||||
moshtariCode: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
const existing = await this.getPerson(organizationId, personId);
|
||||
const picked = pickName(row).trim();
|
||||
const named = picked
|
||||
? applyPersonGenderTitle({ firstName: picked, lastName: '' })
|
||||
: null;
|
||||
const nameChanged =
|
||||
named != null &&
|
||||
namesDiffer(
|
||||
existing.firstName,
|
||||
existing.lastName,
|
||||
named.firstName,
|
||||
named.lastName,
|
||||
);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
...(named
|
||||
? {
|
||||
firstName: named.firstName,
|
||||
lastName: named.lastName,
|
||||
gender: named.gender,
|
||||
showGenderInName: named.showGenderInName,
|
||||
}
|
||||
: {}),
|
||||
...(nameChanged
|
||||
? {
|
||||
previousFirstName: existing.firstName || null,
|
||||
previousLastName: existing.lastName || null,
|
||||
}
|
||||
: {}),
|
||||
...carryPreviousSource(existing, 'tahesab'),
|
||||
importSource: 'tahesab',
|
||||
sourceRef: String(moshtariCode),
|
||||
...(!existing.birthDate && pickBirthDate(row)
|
||||
? { birthDate: pickBirthDate(row) }
|
||||
: {}),
|
||||
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
|
||||
},
|
||||
});
|
||||
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ مشتریان تهحساب را صفحه به صفحه میخواند.
|
||||
* کد تازه وارد میشود؛ کد لینکشده نام و شماره و گروهش تازه میشود.
|
||||
*/
|
||||
async syncAllCustomers(organizationId: string, connectionId: string) {
|
||||
const { api } = await this.connections.getApi(organizationId, connectionId);
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
const maxPages = 300;
|
||||
|
||||
while (emptyStreak < 2 && page <= maxPages) {
|
||||
let listed: Awaited<ReturnType<typeof this.listMoshtariPage>>;
|
||||
try {
|
||||
listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خواندن تهحساب ناموفق بود';
|
||||
errors.push(message);
|
||||
break;
|
||||
}
|
||||
if (!listed.rows.length) {
|
||||
emptyStreak += 1;
|
||||
page += 1;
|
||||
continue;
|
||||
}
|
||||
emptyStreak = 0;
|
||||
const rowsByCode = new Map<number, Record<string, unknown>>();
|
||||
for (const row of listed.rows) {
|
||||
const code = pickCode(row);
|
||||
if (code != null && code >= 1) rowsByCode.set(code, row);
|
||||
}
|
||||
const links = rowsByCode.size
|
||||
? await this.prisma.personAccountingLink.findMany({
|
||||
where: {
|
||||
connectionId,
|
||||
moshtariCode: { in: [...rowsByCode.keys()] },
|
||||
},
|
||||
select: { moshtariCode: true, personId: true },
|
||||
})
|
||||
: [];
|
||||
const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
|
||||
for (const [code, row] of rowsByCode) {
|
||||
try {
|
||||
const personId = linkedByCode.get(code);
|
||||
if (personId) {
|
||||
await this.refreshLinkedCustomer(
|
||||
organizationId,
|
||||
connectionId,
|
||||
personId,
|
||||
code,
|
||||
row,
|
||||
);
|
||||
updated += 1;
|
||||
} else {
|
||||
await this.importFromTahesab(
|
||||
organizationId,
|
||||
{ connectionId, moshtariCode: code },
|
||||
{ api, row },
|
||||
{ skipJob: true },
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
if (errors.length < 5) {
|
||||
errors.push(err instanceof Error ? err.message : 'خطا');
|
||||
}
|
||||
}
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (page > maxPages && emptyStreak < 2) {
|
||||
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
|
||||
}
|
||||
|
||||
const summary = `${imported} وارد شد، ${updated} بهروز شد، ${failed} خطا`;
|
||||
await this.prisma.accountingSyncJob.create({
|
||||
data: {
|
||||
organizationId,
|
||||
connectionId: dto.connectionId,
|
||||
kind: 'import',
|
||||
status: 'success',
|
||||
request: dto as unknown as Prisma.InputJsonValue,
|
||||
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
|
||||
connectionId,
|
||||
kind: 'customer-sync',
|
||||
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
|
||||
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return { personId, link: this.linkDto(link) };
|
||||
this.logger.log(`همگامسازی مشتریان تهحساب ${connectionId}: ${summary}`);
|
||||
return {
|
||||
imported,
|
||||
updated,
|
||||
failed,
|
||||
summary,
|
||||
error: errors.length ? errors.join('؛ ') : null,
|
||||
};
|
||||
}
|
||||
|
||||
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
|
||||
import { TahesabDocumentsService } from './documents.service';
|
||||
import { TahesabAsnadReportService } from './asnad-report.service';
|
||||
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
|
||||
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
|
||||
import { TahesabController } from './tahesab.controller';
|
||||
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
|
||||
|
||||
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
|
||||
TahesabDocumentsService,
|
||||
TahesabAsnadReportService,
|
||||
TahesabInvoiceSettingsService,
|
||||
TahesabCustomerSyncScheduler,
|
||||
],
|
||||
exports: [
|
||||
TahesabCryptoService,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { NotificationsService } from '../../notifications/notifications.service';
|
||||
import { normalizePhone } from '../../contacts/normalize';
|
||||
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
|
||||
import { VoipConnectionsService } from './connections.service';
|
||||
|
||||
function digitsOnly(value: string | null | undefined): string {
|
||||
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
|
||||
variants.add(`0${d}`);
|
||||
variants.add(`98${d}`);
|
||||
}
|
||||
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
|
||||
import {
|
||||
normalizeEmail,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../../contacts/normalize';
|
||||
import { WooConnectionsService } from './connections.service';
|
||||
import type { WooOrder } from './woo-client';
|
||||
|
||||
@@ -374,7 +378,8 @@ export class WooSyncService {
|
||||
): Promise<string | null> {
|
||||
const billing = order.billing ?? {};
|
||||
const email = normalizeEmail(billing.email ?? null);
|
||||
const mobile = normalizePhone(billing.phone ?? null);
|
||||
const mobile = storedPhoneNumber(billing.phone ?? null);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const firstName = (billing.first_name ?? '').trim() || 'مشتری';
|
||||
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
|
||||
|
||||
@@ -387,7 +392,9 @@ export class WooSyncService {
|
||||
organizationId,
|
||||
OR: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(mobile ? [{ mobile }, { phone: mobile }] : []),
|
||||
...(mobileKeys.length
|
||||
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
|
||||
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import {
|
||||
canonicalIranMobile,
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
normalizePhone,
|
||||
phoneIdentityKeys,
|
||||
storedPhoneNumber,
|
||||
} from '../contacts/normalize';
|
||||
import { LeadStagesService } from '../leads/lead-stages.service';
|
||||
|
||||
@@ -458,7 +461,10 @@ export class LeadFormsService {
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: form.organizationId,
|
||||
OR: [{ mobile }, { phone: mobile }],
|
||||
OR: [
|
||||
{ mobile: { in: phoneIdentityKeys(mobile) } },
|
||||
{ phone: { in: phoneIdentityKeys(mobile) } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
@@ -641,13 +647,16 @@ export class LeadFormsService {
|
||||
ownerId: string,
|
||||
): Promise<string> {
|
||||
const email = normalizeEmail(mapped.email);
|
||||
const mobile = normalizePhone(mapped.mobile);
|
||||
const phone = normalizePhone(mapped.phone);
|
||||
const mobile = storedPhoneNumber(mapped.mobile);
|
||||
const phone = storedPhoneNumber(mapped.phone);
|
||||
|
||||
const or: Prisma.PersonWhereInput[] = [];
|
||||
if (email) or.push({ email });
|
||||
if (mobile) or.push({ mobile });
|
||||
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
if (mobileKeys.length) {
|
||||
or.push({ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } });
|
||||
}
|
||||
const existing = or.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: { organizationId, OR: or },
|
||||
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
|
||||
}
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const canon = canonicalIranMobile(raw);
|
||||
if (canon) return canon;
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
const d = n.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('98') && d.length >= 12) return `0${d.slice(2)}`;
|
||||
if (d.length === 10 && d.startsWith('9')) return `0${d}`;
|
||||
return d.startsWith('0') ? d : d;
|
||||
return d || null;
|
||||
}
|
||||
|
||||
function normalizeDuplicateMode(raw?: string | null): string {
|
||||
|
||||
@@ -32,12 +32,18 @@ import {
|
||||
forScope,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
import {
|
||||
asAddresses,
|
||||
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';
|
||||
@@ -54,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 {
|
||||
@@ -322,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) {
|
||||
@@ -613,24 +623,43 @@ export class LeadsService {
|
||||
});
|
||||
if (!stage) throw new NotFoundException('مرحله پیدا نشد');
|
||||
|
||||
let placedIds: string[] | null = null;
|
||||
let nextPosition = position ?? 0;
|
||||
if (position != null) {
|
||||
const rows = await this.prisma.lead.findMany({
|
||||
where: { organizationId: actor.organizationId, stageId, archivedAt: null },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, id, position);
|
||||
nextPosition = Math.max(0, placedIds.indexOf(id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId,
|
||||
position: position ?? 0,
|
||||
...(stage.isConverted
|
||||
? {}
|
||||
: {}),
|
||||
position: nextPosition,
|
||||
},
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(leadId, leadPosition) =>
|
||||
this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
|
||||
);
|
||||
}
|
||||
if (before.stageId !== stageId) {
|
||||
await this.addEvent(
|
||||
actor,
|
||||
id,
|
||||
'stage_changed',
|
||||
`به مرحله «${stage.name}» منتقل شد`,
|
||||
);
|
||||
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
|
||||
eventKey: `${id}:${stageId}`,
|
||||
facts: { stageName: stage.name, source: row.source },
|
||||
@@ -946,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;
|
||||
|
||||
@@ -976,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,
|
||||
@@ -1150,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,
|
||||
@@ -1192,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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateOrgRoleDto {
|
||||
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
treasuryAccountIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
treasuryOwnAccounts?: boolean;
|
||||
}
|
||||
|
||||
export class RolePermissionPatchItemDto {
|
||||
|
||||
@@ -32,6 +32,8 @@ type RoleRow = {
|
||||
color: string;
|
||||
permissions: string[];
|
||||
dataAccessPolicy?: unknown;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
systemKey: string | null;
|
||||
parentId: string | null;
|
||||
createdAt: Date;
|
||||
@@ -79,6 +81,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -108,6 +112,8 @@ export class OrgRolesService {
|
||||
description: (dto.description ?? '').trim(),
|
||||
color: dto.color ?? parent?.color ?? 'teal',
|
||||
permissions,
|
||||
treasuryAccountIds: dto.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
|
||||
parentId,
|
||||
...(dto.dataAccessPolicy
|
||||
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
|
||||
@@ -135,6 +141,8 @@ export class OrgRolesService {
|
||||
permissions?: RolePermissionKey[];
|
||||
dataAccessPolicy?: DataAccessPolicy;
|
||||
parentId?: string | null;
|
||||
treasuryAccountIds?: string[];
|
||||
treasuryOwnAccounts?: boolean;
|
||||
},
|
||||
): Promise<OrgRoleDto> {
|
||||
this.assertManager(actorRole);
|
||||
@@ -159,6 +167,12 @@ export class OrgRolesService {
|
||||
if (dto.dataAccessPolicy !== undefined) {
|
||||
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
|
||||
}
|
||||
if (dto.treasuryAccountIds !== undefined) {
|
||||
data.treasuryAccountIds = [...new Set(dto.treasuryAccountIds.map((id) => id.trim()).filter(Boolean))];
|
||||
}
|
||||
if (dto.treasuryOwnAccounts !== undefined) {
|
||||
data.treasuryOwnAccounts = dto.treasuryOwnAccounts;
|
||||
}
|
||||
|
||||
if (dto.parentId !== undefined) {
|
||||
const nextParentId =
|
||||
@@ -426,6 +440,8 @@ export class OrgRolesService {
|
||||
color: row.color,
|
||||
permissions: sanitizePermissions(row.permissions),
|
||||
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
|
||||
treasuryAccountIds: row.treasuryAccountIds ?? [],
|
||||
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
|
||||
systemKey,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
|
||||
import { placeId, sameIds, writePositions } from '../common/column-order';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -379,7 +380,8 @@ export class DealsService {
|
||||
}
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const position =
|
||||
let placedIds: string[] | null = null;
|
||||
let position =
|
||||
dto.position ??
|
||||
((
|
||||
await this.prisma.deal.aggregate({
|
||||
@@ -387,6 +389,17 @@ export class DealsService {
|
||||
_max: { position: true },
|
||||
})
|
||||
)._max.position ?? -1) + 1;
|
||||
if (dto.position != null) {
|
||||
const rows = await this.prisma.deal.findMany({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
select: { id: true },
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
const currentIds = rows.map((row) => row.id);
|
||||
placedIds = placeId(currentIds, deal.id, dto.position);
|
||||
position = Math.max(0, placedIds.indexOf(deal.id));
|
||||
if (sameIds(currentIds, placedIds)) placedIds = null;
|
||||
}
|
||||
|
||||
const row = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
@@ -402,6 +415,15 @@ export class DealsService {
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
if (placedIds) {
|
||||
await writePositions(
|
||||
(ops) => this.prisma.$transaction(ops),
|
||||
placedIds,
|
||||
(dealId, nextPosition) =>
|
||||
this.prisma.deal.update({ where: { id: dealId }, data: { position: nextPosition } }),
|
||||
);
|
||||
}
|
||||
|
||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
|
||||
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||
import type { PortalActor } from './portal-auth';
|
||||
|
||||
@@ -62,7 +63,7 @@ export class PortalService {
|
||||
mobileRaw: string,
|
||||
): Promise<PortalRequestOtpResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
@@ -123,7 +124,8 @@ export class PortalService {
|
||||
codeRaw: string,
|
||||
): Promise<PortalLoginResponse> {
|
||||
const org = await this.findOrg(slug);
|
||||
const mobile = normalizeMobile(mobileRaw);
|
||||
const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
|
||||
const mobileKeys = phoneIdentityKeys(mobile);
|
||||
const code = codeRaw.trim();
|
||||
if (!mobile || !code) {
|
||||
throw new BadRequestException('موبایل یا کد ناقص است');
|
||||
@@ -148,10 +150,15 @@ export class PortalService {
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
|
||||
let person = await this.prisma.person.findFirst({
|
||||
where: { organizationId: org.id, mobile },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
let person = mobileKeys.length
|
||||
? await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: org.id,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
: null;
|
||||
if (!person) {
|
||||
person = await this.prisma.person.create({
|
||||
data: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { projectCardAccessWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
formatJalaliDate,
|
||||
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
|
||||
if (mobileKeys.length) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: string;
|
||||
role: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyName?: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
|
||||
organizationId: auth.organizationId,
|
||||
role: auth.role,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
apiKeyName: auth.name,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ApiKeysService {
|
||||
|
||||
async authenticate(
|
||||
rawKey: string,
|
||||
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> {
|
||||
): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
|
||||
const key = rawKey.trim();
|
||||
if (!key.startsWith('mk_') || key.length < 20) {
|
||||
throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
@@ -71,7 +71,7 @@ export class ApiKeysService {
|
||||
const keyHash = this.hashKey(key);
|
||||
const row = await this.prisma.organizationApiKey.findFirst({
|
||||
where: { keyHash, revokedAt: null },
|
||||
select: { id: true, organizationId: true },
|
||||
select: { id: true, organizationId: true, name: true },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
|
||||
|
||||
@@ -83,6 +83,7 @@ export class ApiKeysService {
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
apiKeyId: row.id,
|
||||
name: row.name,
|
||||
role: 'owner',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { LeadsService } from '../leads/leads.service';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
|
||||
class CreateExternalLeadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
lastName?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(20)
|
||||
mobile!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
storeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const LEAD_RATE_WINDOW_MS = 60_000;
|
||||
const LEAD_RATE_MAX = 60;
|
||||
const leadHits = new Map<string, number[]>();
|
||||
|
||||
class ListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -35,7 +72,36 @@ class ListQueryDto {
|
||||
@Controller('public/v1')
|
||||
@UseGuards(ApiKeyAuthGuard)
|
||||
export class PublicApiV1Controller {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly leads: LeadsService,
|
||||
) {}
|
||||
|
||||
@Post('leads')
|
||||
@HttpCode(200)
|
||||
createLead(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; apiKeyId?: string; apiKeyName?: string },
|
||||
@Body() body: CreateExternalLeadDto,
|
||||
) {
|
||||
const bucket = user.apiKeyId || user.organizationId;
|
||||
const now = Date.now();
|
||||
const prev = (leadHits.get(bucket) ?? []).filter((t) => now - t < LEAD_RATE_WINDOW_MS);
|
||||
if (prev.length >= LEAD_RATE_MAX) {
|
||||
throw new HttpException('تعداد ارسال بیش از حد مجاز است. کمی بعد دوباره تلاش کنید.', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
prev.push(now);
|
||||
leadHits.set(bucket, prev);
|
||||
return this.leads.createFromExternal({
|
||||
organizationId: user.organizationId,
|
||||
siteName: user.apiKeyName?.trim() || 'سایت بیرونی',
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
mobile: body.mobile,
|
||||
storeName: body.storeName,
|
||||
source: body.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('people')
|
||||
async listPeople(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { LeadsModule } from '../leads/leads.module';
|
||||
import { ApiKeyAuthGuard } from './api-key-auth.guard';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
imports: [AuthModule, BillingModule, LeadsModule],
|
||||
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
|
||||
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
|
||||
exports: [ApiKeysService, WebhooksService],
|
||||
|
||||
@@ -185,6 +185,7 @@ export function toRoutineDto(
|
||||
entityLink: normalizeEntityLink(row.entityLink),
|
||||
reminderMinutesBefore: row.reminderMinutesBefore,
|
||||
isActive: row.isActive,
|
||||
createdById: row.createdById,
|
||||
recurrenceLabel: describeRecurrence(rule),
|
||||
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
|
||||
executionTotal: execution.total,
|
||||
|
||||
@@ -200,8 +200,7 @@ export class RoutinesService {
|
||||
|
||||
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -216,10 +215,9 @@ export class RoutinesService {
|
||||
|
||||
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, dto);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: data as Prisma.RoutineUncheckedUpdateInput,
|
||||
@@ -232,9 +230,8 @@ export class RoutinesService {
|
||||
|
||||
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const row = await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
@@ -251,9 +248,8 @@ export class RoutinesService {
|
||||
dto: ChangeRoutineAssigneeDto,
|
||||
): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
|
||||
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
|
||||
const row = await this.prisma.routine.update({
|
||||
@@ -268,9 +264,8 @@ export class RoutinesService {
|
||||
|
||||
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const src = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, src);
|
||||
this.assertCanWriteRoutine(scope, src);
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
@@ -295,9 +290,8 @@ export class RoutinesService {
|
||||
|
||||
async remove(actor: Actor, id: string) {
|
||||
const scope = await this.scope(actor);
|
||||
assertManager(scope);
|
||||
const existing = await this.findRoutine(actor.organizationId, id);
|
||||
this.assertCanManageRoutine(scope, existing);
|
||||
this.assertCanWriteRoutine(scope, existing);
|
||||
await this.prisma.routine.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), isActive: false },
|
||||
@@ -345,6 +339,27 @@ export class RoutinesService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** عضو عادی فقط روتینی را مینویسد که خودش برای خودش ساخته است. */
|
||||
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
|
||||
if (scope.isManager) return dto;
|
||||
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
|
||||
throw new ForbiddenException('فقط میتوانید برای خودتان روتین بسازید');
|
||||
}
|
||||
return { ...dto, assigneeId: actor.userId, teamId: null };
|
||||
}
|
||||
|
||||
private assertCanWriteRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
) {
|
||||
if (scope.isManager) {
|
||||
this.assertCanManageRoutine(scope, row);
|
||||
return;
|
||||
}
|
||||
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
|
||||
throw new ForbiddenException('فقط روتینی که برای خودتان ساختهاید را میتوانید تغییر دهید');
|
||||
}
|
||||
|
||||
private assertCanManageRoutine(
|
||||
scope: RoutineScope,
|
||||
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
parseUserTreasuryAccess,
|
||||
treasurySectionAllowed,
|
||||
type TreasurySectionKey,
|
||||
} from '@mento/shared';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* حقوق فقط با treasury_payroll.
|
||||
* بقیه مسیرها با treasury باز میشوند؛ اگر بخش ریز انتخاب شده باشد فقط همانها.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TreasuryAccessGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -10,20 +19,56 @@ export class TreasuryAccessGuard implements CanActivate {
|
||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||
const actor = req.user;
|
||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||
const url = req.originalUrl || req.url || '';
|
||||
const payroll = url.includes('/treasury/payroll');
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
select: {
|
||||
orgRole: { select: { permissions: true } },
|
||||
treasuryAccessOverride: true,
|
||||
},
|
||||
});
|
||||
const permissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
const allowed = payroll
|
||||
? permissions.includes('treasury_payroll')
|
||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
||||
const permissions = permissionsForTreasury(
|
||||
rolePermissions,
|
||||
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
|
||||
);
|
||||
const url = req.originalUrl || req.url || '';
|
||||
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
|
||||
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionsForTreasury(
|
||||
rolePermissions: string[],
|
||||
override: ReturnType<typeof parseUserTreasuryAccess>,
|
||||
): string[] {
|
||||
if (!override) return rolePermissions;
|
||||
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
|
||||
if (!override.enabled) return rest;
|
||||
return ['treasury', ...override.sections, ...rest];
|
||||
}
|
||||
|
||||
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
|
||||
if (url.includes('/treasury/payroll')) return 'treasury_payroll';
|
||||
if (url.includes('/treasury/receivables') || url.includes('/treasury/installments')) {
|
||||
return 'treasury_receivables';
|
||||
}
|
||||
if (url.includes('/treasury/recurring')) return 'treasury_recurring';
|
||||
if (url.includes('/treasury/reports')) return 'treasury_reports';
|
||||
if (url.includes('/treasury/audit')) return 'treasury_audit';
|
||||
if (url.includes('/treasury/categories')) return 'treasury_categories';
|
||||
if (
|
||||
url.includes('/treasury/accounts') ||
|
||||
url.includes('/treasury/transfers') ||
|
||||
url.includes('/treasury/reconciliations') ||
|
||||
url.includes('/treasury/adjustments')
|
||||
) {
|
||||
return 'treasury_accounts';
|
||||
}
|
||||
if (url.includes('/treasury/dashboard')) return 'treasury_dashboard';
|
||||
return 'treasury_documents';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
|
||||
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown };
|
||||
}
|
||||
|
||||
async reportExcel(actor: Actor, from?: string, to?: string) {
|
||||
async reportExcel(
|
||||
actor: Actor,
|
||||
from?: string,
|
||||
to?: string,
|
||||
parties?: { debtors: TreasuryPartyBalanceDto[]; creditors: TreasuryPartyBalanceDto[] },
|
||||
) {
|
||||
const data = await this.report(actor, from, to);
|
||||
const book = new ExcelJS.Workbook();
|
||||
const sheet = book.addWorksheet('گزارش مالی');
|
||||
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
|
||||
sheet.addRow([]);
|
||||
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
|
||||
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share]));
|
||||
if (parties) {
|
||||
this.addPartySheet(book, 'بدهکاران', parties.debtors, 'دریافتشده');
|
||||
this.addPartySheet(book, 'بستانکاران', parties.creditors, 'پرداختشده');
|
||||
}
|
||||
const buffer = await book.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private addPartySheet(
|
||||
book: ExcelJS.Workbook,
|
||||
title: string,
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
settledLabel: string,
|
||||
) {
|
||||
const sheet = book.addWorksheet(title);
|
||||
const kindLabel: Record<string, string> = { person: 'مشتری', company: 'شرکت', user: 'کاربر' };
|
||||
sheet.addRow(['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده']);
|
||||
let amount = new Prisma.Decimal(0);
|
||||
let settled = new Prisma.Decimal(0);
|
||||
let remaining = new Prisma.Decimal(0);
|
||||
for (const row of rows) {
|
||||
sheet.addRow([
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
row.amount,
|
||||
row.settledAmount,
|
||||
row.remaining,
|
||||
]);
|
||||
amount = amount.plus(row.amount);
|
||||
settled = settled.plus(row.settledAmount);
|
||||
remaining = remaining.plus(row.remaining);
|
||||
}
|
||||
sheet.addRow(['جمع', '', money(amount), money(settled), money(remaining)]);
|
||||
}
|
||||
|
||||
private async period(organizationId: string, from: Date, to: Date) {
|
||||
const docs = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
@@ -61,7 +75,18 @@ export class TreasuryController {
|
||||
updateAccount(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
type?: 'bank' | 'cash' | 'petty';
|
||||
bankName?: string | null;
|
||||
accountRef?: string | null;
|
||||
notes?: string | null;
|
||||
isActive?: boolean;
|
||||
responsibleName?: string | null;
|
||||
openingBalance?: string | number;
|
||||
openingDate?: string;
|
||||
},
|
||||
) {
|
||||
return this.treasury.updateAccount(user, id, body);
|
||||
}
|
||||
@@ -81,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,
|
||||
@@ -221,7 +269,11 @@ export class TreasuryController {
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
) {
|
||||
const buffer = await this.ops.reportExcel(user, from, to);
|
||||
const [debtors, creditors] = await Promise.all([
|
||||
this.treasury.partyBalances(user, 'income'),
|
||||
this.treasury.partyBalances(user, 'expense'),
|
||||
]);
|
||||
const buffer = await this.ops.reportExcel(user, from, to, { debtors, creditors });
|
||||
return new StreamableFile(buffer, {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
disposition: 'attachment; filename="treasury-report.xlsx"',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ContactsModule } from '../contacts/contacts.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
imports: [AuthModule, NotificationsModule, ContactsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { parseUserTreasuryAccess } from '@mento/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
@@ -11,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';
|
||||
@@ -63,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) {
|
||||
@@ -110,27 +120,80 @@ 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(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<FinancialAccountDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const accounts = await this.prisma.financialAccount.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...(scope ? { id: { in: scope } } : {}),
|
||||
},
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
|
||||
@@ -196,19 +259,89 @@ export class TreasuryService {
|
||||
async updateAccount(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; notes?: string | null; isActive?: boolean; responsibleName?: string | null },
|
||||
input: {
|
||||
name?: string;
|
||||
type?: 'bank' | 'cash' | 'petty';
|
||||
bankName?: string | null;
|
||||
accountRef?: string | null;
|
||||
notes?: string | null;
|
||||
isActive?: boolean;
|
||||
responsibleName?: string | null;
|
||||
openingBalance?: string | number;
|
||||
openingDate?: string;
|
||||
},
|
||||
): Promise<FinancialAccountDto> {
|
||||
const existing = await this.requireAccount(actor.organizationId, id);
|
||||
const row = await this.prisma.financialAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||
...(input.responsibleName !== undefined
|
||||
? { responsibleName: input.responsibleName?.trim() || null }
|
||||
: {}),
|
||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||
},
|
||||
const existing = await this.requireAccount(actor.organizationId, id, actor);
|
||||
if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
|
||||
throw new BadRequestException('نوع حساب نامعتبر است.');
|
||||
}
|
||||
const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
|
||||
if (opening?.isNegative()) {
|
||||
throw new BadRequestException('موجودی اولیه نمیتواند منفی باشد.');
|
||||
}
|
||||
const openingDate =
|
||||
input.openingDate !== undefined
|
||||
? (startOfDay(input.openingDate) ?? existing.openingDate)
|
||||
: null;
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.financialAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(input.name?.trim() ? { name: input.name.trim() } : {}),
|
||||
...(input.type ? { type: input.type } : {}),
|
||||
...(input.bankName !== undefined ? { bankName: input.bankName?.trim() || null } : {}),
|
||||
...(input.accountRef !== undefined ? { accountRef: input.accountRef?.trim() || null } : {}),
|
||||
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||
...(input.responsibleName !== undefined
|
||||
? { responsibleName: input.responsibleName?.trim() || null }
|
||||
: {}),
|
||||
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
|
||||
...(opening ? { openingBalance: opening } : {}),
|
||||
...(openingDate ? { openingDate } : {}),
|
||||
},
|
||||
});
|
||||
if (opening || openingDate) {
|
||||
const nextAmount = opening ?? existing.openingBalance;
|
||||
const nextDate = openingDate ?? existing.openingDate;
|
||||
const openings = await tx.financialMovement.findMany({
|
||||
where: { accountId: existing.id, kind: 'opening', voidedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (nextAmount.isZero()) {
|
||||
if (openings.length) {
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { id: { in: openings.map((row) => row.id) } },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
} else if (openings[0]) {
|
||||
await tx.financialMovement.update({
|
||||
where: { id: openings[0].id },
|
||||
data: { amount: nextAmount, occurredAt: nextDate, direction: 'in' },
|
||||
});
|
||||
const extra = openings.slice(1).map((row) => row.id);
|
||||
if (extra.length) {
|
||||
await tx.financialMovement.updateMany({
|
||||
where: { id: { in: extra } },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.financialMovement.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
accountId: existing.id,
|
||||
direction: 'in',
|
||||
amount: nextAmount,
|
||||
kind: 'opening',
|
||||
occurredAt: nextDate,
|
||||
memo: 'مانده افتتاحیه',
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return this.toAccountDto(row);
|
||||
}
|
||||
@@ -218,7 +351,7 @@ export class TreasuryService {
|
||||
accountId: string,
|
||||
range?: { from?: string; to?: string },
|
||||
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> {
|
||||
const account = await this.requireAccount(actor.organizationId, accountId);
|
||||
const account = await this.requireAccount(actor.organizationId, accountId, actor);
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
@@ -303,8 +436,8 @@ export class TreasuryService {
|
||||
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
|
||||
}
|
||||
const [source, target] = await Promise.all([
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId),
|
||||
this.requireAccount(actor.organizationId, input.fromAccountId, actor),
|
||||
this.requireAccount(actor.organizationId, input.toAccountId, actor),
|
||||
]);
|
||||
if (!source.isActive || !target.isActive) {
|
||||
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
|
||||
@@ -354,10 +487,12 @@ export class TreasuryService {
|
||||
kind: 'income' | 'expense',
|
||||
q?: string,
|
||||
): Promise<FinancialDocumentDto[]> {
|
||||
const scope = await this.accountScope(actor);
|
||||
const rows = await this.prisma.financialDocument.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
kind,
|
||||
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
@@ -384,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;
|
||||
@@ -398,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('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||
}
|
||||
@@ -408,7 +550,7 @@ export class TreasuryService {
|
||||
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
|
||||
}
|
||||
if (input.accountId) {
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (input.kind === 'expense' && settle.greaterThan(0)) {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
@@ -419,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,
|
||||
@@ -428,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,
|
||||
},
|
||||
@@ -449,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,
|
||||
},
|
||||
});
|
||||
@@ -462,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,
|
||||
@@ -482,7 +849,7 @@ export class TreasuryService {
|
||||
if (amount.greaterThan(remaining)) {
|
||||
throw new BadRequestException('مبلغ از مانده سند بیشتر است.');
|
||||
}
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId);
|
||||
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
|
||||
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
|
||||
if (doc.kind === 'expense') {
|
||||
const balance = await this.accountBalance(account.id);
|
||||
@@ -644,9 +1011,14 @@ export class TreasuryService {
|
||||
): Promise<TreasuryDashboardDto> {
|
||||
const from = startOfDay(range?.from);
|
||||
const to = startOfDay(range?.to);
|
||||
const scope = await this.accountScope(actor);
|
||||
const accountFilter = scope
|
||||
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
|
||||
: {};
|
||||
const bookedWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
status: { not: 'void' },
|
||||
...accountFilter,
|
||||
...(from || to
|
||||
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
@@ -654,6 +1026,7 @@ export class TreasuryService {
|
||||
const cashWhere = {
|
||||
organizationId: actor.organizationId,
|
||||
voidedAt: null,
|
||||
...(scope ? { accountId: { in: scope } } : {}),
|
||||
...(from || to
|
||||
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
|
||||
: {}),
|
||||
@@ -680,12 +1053,13 @@ export class TreasuryService {
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
status: { in: ['open', 'partial'] },
|
||||
...accountFilter,
|
||||
},
|
||||
include: { movements: true },
|
||||
}),
|
||||
this.listAccounts(actor),
|
||||
this.prisma.financialDocument.findMany({
|
||||
where: { organizationId: actor.organizationId },
|
||||
where: { organizationId: actor.organizationId, ...accountFilter },
|
||||
include: { category: true, account: true, movements: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
@@ -754,11 +1128,50 @@ export class TreasuryService {
|
||||
);
|
||||
}
|
||||
|
||||
private async requireAccount(organizationId: string, id: string) {
|
||||
private async accountScope(actor: Actor): Promise<string[] | null> {
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return null;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: {
|
||||
user: { select: { fullName: true } },
|
||||
orgRole: { select: { treasuryAccountIds: true, treasuryOwnAccounts: true } },
|
||||
treasuryAccessOverride: true,
|
||||
},
|
||||
});
|
||||
const override = parseUserTreasuryAccess(membership?.treasuryAccessOverride);
|
||||
if (override && !override.enabled) return [];
|
||||
const extra = override
|
||||
? override.accountIds
|
||||
: (membership?.orgRole?.treasuryAccountIds ?? []);
|
||||
const own = override
|
||||
? override.ownAccounts
|
||||
: Boolean(membership?.orgRole?.treasuryOwnAccounts);
|
||||
if (!own && extra.length === 0) return null;
|
||||
const ids = new Set(extra);
|
||||
const name = membership?.user.fullName?.trim();
|
||||
if (own && name) {
|
||||
const rows = await this.prisma.financialAccount.findMany({
|
||||
where: { organizationId: actor.organizationId, responsibleName: { not: null } },
|
||||
select: { id: true, responsibleName: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.responsibleName?.trim() === name) ids.add(row.id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
private async requireAccount(organizationId: string, id: string, actor?: Actor) {
|
||||
const row = await this.prisma.financialAccount.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('حساب پیدا نشد.');
|
||||
if (actor) {
|
||||
const scope = await this.accountScope(actor);
|
||||
if (scope && !scope.includes(row.id)) {
|
||||
throw new ForbiddenException('به این حساب دسترسی ندارید.');
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -824,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;
|
||||
@@ -851,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,
|
||||
|
||||
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
|
||||
/** null = ارث از نقش */
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
UpdateUserVisibilityRequest,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
|
||||
import {
|
||||
DATA_SCOPES,
|
||||
DATA_VISIBILITIES,
|
||||
parseDataAccessPolicy,
|
||||
parseUserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import { resolveAllSectionAccess } from '../common/data-access';
|
||||
import { parseVisibilityScopes } from '../common/record-scope';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -47,6 +52,7 @@ type MembershipRow = {
|
||||
dataVisibility?: string | null;
|
||||
dataVisibilityScopes?: unknown;
|
||||
dataAccessOverride?: unknown;
|
||||
treasuryAccessOverride?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
@@ -370,23 +376,35 @@ export class UsersService {
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
targetId: string,
|
||||
dto: { dataAccessOverride?: Record<string, unknown> | null },
|
||||
dto: {
|
||||
dataAccessOverride?: Record<string, unknown> | null;
|
||||
treasuryAccessOverride?: Record<string, unknown> | null;
|
||||
},
|
||||
): Promise<OrgUserDto> {
|
||||
if (actorRole !== 'owner' && actorRole !== 'admin') {
|
||||
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
|
||||
}
|
||||
if (dto.dataAccessOverride === undefined) {
|
||||
if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
|
||||
throw new BadRequestException('تغییری ارسال نشده است');
|
||||
}
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
if (target.role === 'owner' || target.role === 'admin') {
|
||||
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
|
||||
}
|
||||
const next =
|
||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||
const data: Prisma.MembershipUpdateInput = {};
|
||||
if (dto.dataAccessOverride !== undefined) {
|
||||
data.dataAccessOverride =
|
||||
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
|
||||
}
|
||||
if (dto.treasuryAccessOverride !== undefined) {
|
||||
data.treasuryAccessOverride =
|
||||
dto.treasuryAccessOverride === null
|
||||
? Prisma.DbNull
|
||||
: (parseUserTreasuryAccess(dto.treasuryAccessOverride) ?? Prisma.DbNull);
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { dataAccessOverride: next },
|
||||
data,
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
@@ -606,6 +624,10 @@ export class UsersService {
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? {}
|
||||
: parseDataAccessPolicy(row.dataAccessOverride),
|
||||
treasuryAccessOverride:
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? null
|
||||
: parseUserTreasuryAccess(row.treasuryAccessOverride),
|
||||
...(() => {
|
||||
const effectiveDataAccess = resolveAllSectionAccess({
|
||||
role: row.role,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>منتکت | Montact CRM</title>
|
||||
<link rel="icon" type="image/png" href="/brand/logo.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/logo.svg" />
|
||||
<style>
|
||||
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیقتر میکند */
|
||||
.app-frame {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
|
||||
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
|
||||
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
|
||||
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #10897d;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #16c1a8;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #43f7dd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
|
||||
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
|
||||
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
|
||||
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
|
||||
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
|
||||
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -15,7 +15,7 @@ import {
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons-vue';
|
||||
import { ThunderboltOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import type {
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
@@ -73,61 +73,63 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-spin v-if="loading" />
|
||||
<template v-else-if="fields.length">
|
||||
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
|
||||
فیلدهای سفارشی
|
||||
</a-typography-title>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
allow-clear
|
||||
:value="String(displayValue(field.key)) || undefined"
|
||||
:options="selectOptions(field)"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event ?? '')"
|
||||
/>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
|
||||
<template v-else-if="fields.length">
|
||||
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
|
||||
فیلدهای سفارشی
|
||||
</a-typography-title>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
allow-clear
|
||||
:value="String(displayValue(field.key)) || undefined"
|
||||
:options="selectOptions(field)"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event ?? '')"
|
||||
/>
|
||||
|
||||
<a-checkbox
|
||||
v-else-if="field.fieldType === 'boolean'"
|
||||
:id="`cf-${field.key}`"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@update:checked="setValue(field.key, $event)"
|
||||
>
|
||||
فعال
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
v-else-if="field.fieldType === 'boolean'"
|
||||
:id="`cf-${field.key}`"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@update:checked="setValue(field.key, $event)"
|
||||
>
|
||||
فعال
|
||||
</a-checkbox>
|
||||
|
||||
<a-input-number
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
:value="
|
||||
props.modelValue[field.key] != null
|
||||
? Number(props.modelValue[field.key])
|
||||
: undefined
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
:value="
|
||||
props.modelValue[field.key] != null
|
||||
? Number(props.modelValue[field.key])
|
||||
: undefined
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@update:value="setValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<a-input
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@update:value="setValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</CrmSkeleton>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Flex as AFlex,
|
||||
FormItem as AFormItem,
|
||||
Radio as ARadio,
|
||||
RadioGroup as ARadioGroup,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type {
|
||||
DataAccessOwnerKind,
|
||||
DataAccessRule,
|
||||
@@ -15,9 +26,12 @@ import {
|
||||
PERSON_MOODS,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type IdOption = { id: string; name: string };
|
||||
type Choice = { value: string; label: string };
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: SectionDataAccess;
|
||||
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dae" :class="{ compact }">
|
||||
<div class="dae-mode">
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
|
||||
همهٔ {{ sectionLabel }}
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
|
||||
محدود با فیلتر
|
||||
</label>
|
||||
</div>
|
||||
<a-flex vertical :gap="compact ? 8 : 12">
|
||||
<a-radio-group v-model:value="mode" :disabled="disabled">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
|
||||
<a-radio value="rules">محدود با فیلتر</a-radio>
|
||||
</a-flex>
|
||||
</a-radio-group>
|
||||
|
||||
<template v-if="mode === 'rules'">
|
||||
<p class="hint">
|
||||
<a-typography-text type="secondary">
|
||||
فقط رکوردهایی دیده میشوند که با
|
||||
<strong>همهٔ فیلترهای پرشده</strong>
|
||||
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
|
||||
</p>
|
||||
<p v-if="hadMultipleRules" class="hint warn">
|
||||
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
||||
</p>
|
||||
<p class="summary">{{ filterSummary }}</p>
|
||||
</a-typography-text>
|
||||
<a-alert
|
||||
v-if="hadMultipleRules"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود."
|
||||
/>
|
||||
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
|
||||
|
||||
<fieldset :disabled="disabled">
|
||||
<legend>مالک / مسئول</legend>
|
||||
<p class="muted">خالی = بدون محدودیت مالک</p>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.owners?.includes('self')"
|
||||
@change="toggleOwner('self')"
|
||||
/>
|
||||
{{ ownerSelfLabel }}
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="filter.owners?.includes('team')"
|
||||
@change="toggleOwner('team')"
|
||||
/>
|
||||
تیمهای عضو
|
||||
</label>
|
||||
</fieldset>
|
||||
<a-card size="small" title="مالک / مسئول" :bordered="true">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
|
||||
<a-space wrap :size="8">
|
||||
<a-checkbox
|
||||
:checked="filter.owners?.includes('self')"
|
||||
:disabled="disabled"
|
||||
@change="toggleOwner('self')"
|
||||
>
|
||||
{{ ownerSelfLabel }}
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
:checked="filter.owners?.includes('team')"
|
||||
:disabled="disabled"
|
||||
@change="toggleOwner('team')"
|
||||
>
|
||||
تیمهای عضو
|
||||
</a-checkbox>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<label v-for="picker in pickers" :key="picker.legend" class="pick">
|
||||
<span>{{ picker.legend }}</span>
|
||||
<p v-if="picker.loading" class="muted">در حال بارگذاری منبعها…</p>
|
||||
<a-form-item
|
||||
v-for="picker in pickers"
|
||||
:key="picker.legend"
|
||||
:label="picker.legend"
|
||||
:style="{ marginBottom: 0 }"
|
||||
>
|
||||
<a-typography-text v-if="picker.loading" type="secondary">
|
||||
در حال بارگذاری منبعها…
|
||||
</a-typography-text>
|
||||
<a-select
|
||||
v-else
|
||||
:value="pickerValue(picker)"
|
||||
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
|
||||
style="width: 100%"
|
||||
@update:value="setList(picker.key, $event)"
|
||||
/>
|
||||
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
|
||||
</label>
|
||||
<a-typography-text v-if="picker.hint" type="secondary">
|
||||
{{ picker.hint }}
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!hasLimits" class="hint">
|
||||
<a-typography-text v-else-if="!hasLimits" type="secondary">
|
||||
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهدهاند.
|
||||
</p>
|
||||
</div>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dae {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.dae-mode {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.dae-mode label,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
margin-inline-end: 0.55rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hint,
|
||||
.muted,
|
||||
.summary {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
.hint.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
.summary {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
legend {
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.pick > span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.compact fieldset {
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Typography,
|
||||
Radio as ARadio,
|
||||
RadioGroup as ARadioGroup,
|
||||
Checkbox as ACheckbox,
|
||||
Space as ASpace,
|
||||
Input as AInput,
|
||||
} from 'ant-design-vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessSection,
|
||||
FinancialAccountDto,
|
||||
PersonImportSourceStatDto,
|
||||
RolePermissionKey,
|
||||
SectionDataAccess,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
emptySectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DataAccessPolicy;
|
||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||
enabledPermissions?: RolePermissionKey[];
|
||||
disabled?: boolean;
|
||||
/** تبهای عمق داده قفل شوند؛ تب مدیریت مالی جدا میماند */
|
||||
dataLocked?: boolean;
|
||||
showTreasury?: boolean;
|
||||
treasuryAccess?: UserTreasuryAccess | null;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [DataAccessPolicy];
|
||||
'update:treasuryAccess': [UserTreasuryAccess | null];
|
||||
}>();
|
||||
|
||||
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
||||
@@ -47,12 +67,13 @@ const visibleSections = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const active = ref<DataAccessSection>('customers');
|
||||
const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
|
||||
const active = ref<DataAccessSection | 'treasury'>('customers');
|
||||
|
||||
watch(
|
||||
visibleSections,
|
||||
(list) => {
|
||||
if (!list.includes(active.value) && list[0]) active.value = list[0];
|
||||
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -89,6 +110,52 @@ const activityTypes = ref<IdOption[]>([]);
|
||||
const projectBoards = ref<IdOption[]>([]);
|
||||
const projectStages = ref<IdOption[]>([]);
|
||||
const routineCategories = ref<IdOption[]>([]);
|
||||
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
|
||||
const treasuryAccountQuery = ref('');
|
||||
|
||||
const filteredTreasuryAccounts = computed(() => {
|
||||
const query = treasuryAccountQuery.value.trim();
|
||||
if (!query) return treasuryAccounts.value;
|
||||
return treasuryAccounts.value.filter((account) => account.name.includes(query));
|
||||
});
|
||||
|
||||
function emptyTreasuryAccess(): UserTreasuryAccess {
|
||||
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
|
||||
}
|
||||
|
||||
function patchTreasury(next: UserTreasuryAccess | null) {
|
||||
emit('update:treasuryAccess', next);
|
||||
}
|
||||
|
||||
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
|
||||
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
|
||||
}
|
||||
|
||||
function toggleTreasurySection(key: RolePermissionKey) {
|
||||
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||
const sections = current.sections.includes(key)
|
||||
? current.sections.filter((item) => item !== key)
|
||||
: [...current.sections, key];
|
||||
patchTreasury({ ...current, enabled: true, sections });
|
||||
}
|
||||
|
||||
function toggleTreasuryAccount(id: string) {
|
||||
const current = props.treasuryAccess ?? emptyTreasuryAccess();
|
||||
const accountIds = current.accountIds.includes(id)
|
||||
? current.accountIds.filter((item) => item !== id)
|
||||
: [...current.accountIds, id];
|
||||
patchTreasury({ ...current, enabled: true, accountIds });
|
||||
}
|
||||
|
||||
async function loadTreasuryAccounts() {
|
||||
if (!props.showTreasury) return;
|
||||
try {
|
||||
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||||
treasuryAccounts.value = data;
|
||||
} catch {
|
||||
treasuryAccounts.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
await loadCustomerGroups().catch(() => undefined);
|
||||
@@ -176,96 +243,107 @@ async function loadOptions() {
|
||||
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
});
|
||||
|
||||
const stagesForActive = computed(() => {
|
||||
if (active.value === 'leads') return leadStages.value;
|
||||
if (active.value === 'sales') return dealStages.value;
|
||||
if (active.value === 'activities') return activityStages.value;
|
||||
if (active.value === 'projects') return projectStages.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const boardsForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityBoards.value;
|
||||
if (active.value === 'projects') return projectBoards.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const typesForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityTypes.value;
|
||||
if (active.value === 'routines') return routineCategories.value;
|
||||
return [];
|
||||
void loadTreasuryAccounts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dap">
|
||||
<p v-if="!visibleSections.length" class="muted">
|
||||
<div>
|
||||
<a-typography-text v-if="!visibleSections.length" type="secondary">
|
||||
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="s in visibleSections"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: active === s }"
|
||||
role="tab"
|
||||
:aria-selected="active === s"
|
||||
@click="active = s"
|
||||
>
|
||||
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<DataAccessEditor
|
||||
:key="active"
|
||||
:section="active"
|
||||
:model-value="sectionValue(active)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:pipelines="pipelines"
|
||||
:boards="boardsForActive"
|
||||
:activity-types="typesForActive"
|
||||
:import-sources="importSources"
|
||||
:import-sources-ready="importSourcesReady"
|
||||
:import-sources-failed="importSourcesFailed"
|
||||
:disabled="disabled"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(active, $event)"
|
||||
/>
|
||||
</template>
|
||||
</a-typography-text>
|
||||
<a-tabs v-else v-model:activeKey="active">
|
||||
<a-tab-pane
|
||||
v-for="s in visibleSections"
|
||||
:key="s"
|
||||
:tab="DATA_ACCESS_SECTION_LABELS[s]"
|
||||
>
|
||||
<DataAccessEditor
|
||||
:section="s"
|
||||
:model-value="sectionValue(s)"
|
||||
:groups="customerGroups"
|
||||
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
|
||||
:pipelines="pipelines"
|
||||
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
|
||||
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
|
||||
:import-sources="importSources"
|
||||
:import-sources-ready="importSourcesReady"
|
||||
:import-sources-failed="importSourcesFailed"
|
||||
:disabled="disabled || dataLocked"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(s, $event)"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
|
||||
<a-space direction="vertical">
|
||||
<a-typography-text type="secondary">
|
||||
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
||||
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
||||
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
||||
</a-typography-text>
|
||||
<a-radio-group
|
||||
:value="treasuryAccess == null ? 'role' : 'custom'"
|
||||
:disabled="disabled"
|
||||
@change="onTreasuryModeChange"
|
||||
>
|
||||
<a-space direction="vertical">
|
||||
<a-radio value="role">مثل نقش</a-radio>
|
||||
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
|
||||
</a-space>
|
||||
</a-radio-group>
|
||||
<template v-if="treasuryAccess">
|
||||
<a-checkbox
|
||||
:checked="treasuryAccess.enabled"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
||||
>
|
||||
دسترسی به مدیریت مالی
|
||||
</a-checkbox>
|
||||
<template v-if="treasuryAccess.enabled">
|
||||
<a-space direction="vertical">
|
||||
<a-checkbox
|
||||
v-for="perm in treasurySections"
|
||||
:key="perm.key"
|
||||
:checked="treasuryAccess.sections.includes(perm.key)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasurySection(perm.key)"
|
||||
>
|
||||
{{ perm.label }}
|
||||
</a-checkbox>
|
||||
</a-space>
|
||||
<a-checkbox
|
||||
:checked="treasuryAccess.ownAccounts"
|
||||
:disabled="disabled"
|
||||
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
|
||||
>
|
||||
حسابهایی که خودش مسئول آنهاست
|
||||
</a-checkbox>
|
||||
<a-typography-text type="secondary">حسابهای دیگر</a-typography-text>
|
||||
<a-input
|
||||
v-model:value="treasuryAccountQuery"
|
||||
placeholder="جستجوی حساب"
|
||||
:disabled="disabled"
|
||||
allow-clear
|
||||
/>
|
||||
<a-space direction="vertical">
|
||||
<a-checkbox
|
||||
v-for="account in filteredTreasuryAccounts"
|
||||
:key="account.id"
|
||||
:checked="treasuryAccess.accountIds.includes(account.id)"
|
||||
:disabled="disabled"
|
||||
@change="toggleTreasuryAccount(account.id)"
|
||||
>
|
||||
{{ account.name }}
|
||||
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||
</a-checkbox>
|
||||
</a-space>
|
||||
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
|
||||
حسابی برای انتخاب نیست.
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</template>
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dap {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tab {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
}
|
||||
.tab.on {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</template>
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
|
||||
const ASpaceCompact = ASpace.Compact;
|
||||
const AListItem = AList.Item;
|
||||
@@ -259,7 +259,8 @@ onMounted(load);
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="5">
|
||||
<a-list
|
||||
v-if="items.length"
|
||||
item-layout="horizontal"
|
||||
@@ -304,6 +305,7 @@ onMounted(load);
|
||||
description="هنوز چیزی در تایملاین نیست. یک یادداشت یا فعالیت ثبت کنید."
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PrinterOutlined } from '@ant-design/icons-vue';
|
||||
import { PrinterOutlined } from '@/icons/ant-bridge';
|
||||
import { brand } from '@mento/shared';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatMoney, currencySuffix } from '@/lib/money';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
List as AList,
|
||||
Modal,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
|
||||
import { formatJalaliDateTime } from '@/lib/jalali';
|
||||
@@ -163,7 +163,8 @@ onMounted(load);
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="4">
|
||||
<a-typography-text v-if="!loading && !hasItems" type="secondary">
|
||||
هنوز موردی در حافظه ثبت نشده.
|
||||
</a-typography-text>
|
||||
@@ -201,6 +202,7 @@ onMounted(load);
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
@@ -3,35 +3,59 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { USER_ROLE_LABELS } from '@/lib/roles';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
|
||||
import type { OrganizationMembershipDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
variant?: 'default' | 'workspace';
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
variant: 'default',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const orgs = ref<OrganizationMembershipDto[]>([]);
|
||||
const open = ref(false);
|
||||
const createOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const error = ref('');
|
||||
const newName = ref('');
|
||||
const newField = ref<BusinessField>('personal');
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const menuEl = ref<HTMLElement | null>(null);
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
let ignoreDocClickUntil = 0;
|
||||
|
||||
const currentName = computed(
|
||||
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
|
||||
() =>
|
||||
auth.user?.organizationName ||
|
||||
orgs.value.find((o) => o.id === auth.user?.organizationId)?.name ||
|
||||
'محیط کاری',
|
||||
);
|
||||
|
||||
const currentSubtitle = computed(() => {
|
||||
const current = orgs.value.find((o) => o.id === auth.user?.organizationId);
|
||||
if (current?.orgRoleName) return current.orgRoleName;
|
||||
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
|
||||
return 'محیط کاری';
|
||||
});
|
||||
|
||||
const currentInitial = computed(() => {
|
||||
const name = currentName.value.trim();
|
||||
return name ? name.charAt(0) : 'م';
|
||||
});
|
||||
|
||||
const canSwitch = computed(() => orgs.value.length > 1);
|
||||
const isWorkspace = computed(() => props.variant === 'workspace');
|
||||
/** در حالت workspace منو همیشه باز میشود؛ در حالت پیشفرض فقط وقتی بیش از یکی باشد */
|
||||
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
@@ -49,34 +73,62 @@ async function loadOrgs() {
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (!canSwitch.value) return;
|
||||
if (!menuEnabled.value) return;
|
||||
error.value = '';
|
||||
if (open.value) {
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
ignoreDocClickUntil = Date.now() + 400;
|
||||
open.value = true;
|
||||
emit('open');
|
||||
await nextTick();
|
||||
placeMenu();
|
||||
await loadOrgs();
|
||||
await nextTick();
|
||||
placeMenu();
|
||||
}
|
||||
|
||||
function placeMenu() {
|
||||
const btn = rootEl.value?.querySelector('button');
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const box = btn.getBoundingClientRect();
|
||||
const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200));
|
||||
const sidebar = document.getElementById('app-sidebar');
|
||||
const rail = sidebar?.getBoundingClientRect();
|
||||
// لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px
|
||||
const edge = rail?.left ?? box.left;
|
||||
const beside = {
|
||||
right: 'auto',
|
||||
left: `${Math.round(edge - 4)}px`,
|
||||
transform: 'translateX(-100%)',
|
||||
minWidth: `${minWidth}px`,
|
||||
};
|
||||
if (isWorkspace.value) {
|
||||
menuStyle.value = {
|
||||
...beside,
|
||||
top: `${Math.round(box.top)}px`,
|
||||
bottom: 'auto',
|
||||
};
|
||||
return;
|
||||
}
|
||||
menuStyle.value = {
|
||||
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
|
||||
right: `${Math.round(window.innerWidth - box.right)}px`,
|
||||
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
|
||||
...beside,
|
||||
top: 'auto',
|
||||
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
async function choose(org: OrganizationMembershipDto) {
|
||||
if (org.id === auth.user?.organizationId) {
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -90,42 +142,16 @@ async function choose(org: OrganizationMembershipDto) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
open.value = false;
|
||||
newName.value = '';
|
||||
newField.value = 'personal';
|
||||
error.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام محیط باید حداقل دو حرف باشد.';
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await auth.createOrganization({
|
||||
name,
|
||||
businessField: newField.value,
|
||||
});
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ساخت محیط ناموفق بود');
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick(event: MouseEvent) {
|
||||
if (!open.value || Date.now() < ignoreDocClickUntil) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
defineExpose({ close: closeMenu });
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrgs();
|
||||
document.addEventListener('click', onDocClick);
|
||||
@@ -137,27 +163,46 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="org-switch">
|
||||
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
|
||||
<button
|
||||
class="org-btn"
|
||||
:class="{ compact, static: !canSwitch }"
|
||||
:class="{
|
||||
compact,
|
||||
static: !menuEnabled,
|
||||
'org-ws': isWorkspace,
|
||||
open,
|
||||
}"
|
||||
type="button"
|
||||
:title="currentName"
|
||||
:aria-disabled="!canSwitch"
|
||||
:aria-expanded="open"
|
||||
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
|
||||
:aria-disabled="!menuEnabled"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>{{ currentName }}</span>
|
||||
<template v-if="isWorkspace">
|
||||
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
|
||||
<span class="org-copy">
|
||||
<strong>{{ currentName }}</strong>
|
||||
<small>{{ currentSubtitle }}</small>
|
||||
</span>
|
||||
<i class="org-chev" :class="{ open }" aria-hidden="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>{{ currentName }}</span>
|
||||
</template>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open && canSwitch"
|
||||
v-if="open && menuEnabled"
|
||||
ref="menuEl"
|
||||
class="menu"
|
||||
:class="{ 'org-ws': isWorkspace }"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
aria-label="محیطهای کاری"
|
||||
>
|
||||
<p class="hint">ورود به محیط</p>
|
||||
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
|
||||
<button
|
||||
v-for="org in orgs"
|
||||
:key="org.id"
|
||||
@@ -165,41 +210,25 @@ onUnmounted(() => {
|
||||
class="org-item"
|
||||
:class="{ on: org.id === auth.user?.organizationId }"
|
||||
:disabled="loading"
|
||||
role="option"
|
||||
:aria-selected="org.id === auth.user?.organizationId"
|
||||
@click="choose(org)"
|
||||
>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
<span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
|
||||
<span class="org-item-copy">
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
</span>
|
||||
<UiIcon
|
||||
v-if="org.id === auth.user?.organizationId"
|
||||
class="org-check"
|
||||
name="check"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" class="create" :disabled="loading" @click="openCreate">
|
||||
محیط جدید
|
||||
</button>
|
||||
<p v-if="error && !createOpen" class="err">{{ error }}</p>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
|
||||
<form class="form" @submit.prevent="submitCreate">
|
||||
<label>
|
||||
نام محیط
|
||||
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
|
||||
</label>
|
||||
<label>
|
||||
زمینه فعالیت
|
||||
<select v-model="newField">
|
||||
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
||||
{{ BUSINESS_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
|
||||
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
|
||||
</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -234,7 +263,7 @@ onUnmounted(() => {
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
.org-btn span {
|
||||
.org-btn > span {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -253,7 +282,7 @@ onUnmounted(() => {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.org-btn.compact {
|
||||
.org-btn.compact:not(.org-ws) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@@ -262,18 +291,113 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.org-btn.compact span {
|
||||
.org-btn.compact:not(.org-ws) > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-btn.compact:hover {
|
||||
.org-btn.compact:not(.org-ws):hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.org-btn.compact.static:hover {
|
||||
.org-btn.compact.static:not(.org-ws):hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.org-btn.org-ws {
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 16px;
|
||||
color: #0f172a;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.org-btn.org-ws:hover,
|
||||
.org-btn.org-ws.open {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-btn.org-ws.static:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent-soft) 80%, white);
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.org-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.org-copy strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-copy small {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-chev {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-right: 1.6px solid #94a3b8;
|
||||
border-bottom: 1.6px solid #94a3b8;
|
||||
/* RTL: اشاره به بیرون (چپ) */
|
||||
transform: rotate(135deg);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 0.15rem;
|
||||
}
|
||||
|
||||
.org-chev.open {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws .org-copy,
|
||||
.org-btn.compact.org-ws .org-chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws .org-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
@@ -286,11 +410,21 @@ onUnmounted(() => {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.menu.org-ws {
|
||||
padding: 0.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e8eef3;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
padding: 0.35rem 0.65rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.org-item,
|
||||
@@ -305,29 +439,89 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-item strong {
|
||||
font-size: 0.82rem;
|
||||
.menu.org-ws .org-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-item span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
.org-item-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-item-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.org-item strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-item span,
|
||||
.org-item-copy span {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-check {
|
||||
color: #0f172a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-item:hover,
|
||||
.create:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, white);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item:hover .org-item-mark {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.org-item.on {
|
||||
background: var(--accent-soft);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.create {
|
||||
margin-top: 0.15rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
.menu.org-ws .org-item.on {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item.on .org-item-mark {
|
||||
background: color-mix(in srgb, var(--accent-soft) 85%, white);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item.on strong {
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.err {
|
||||
@@ -335,41 +529,4 @@ onUnmounted(() => {
|
||||
color: #b91c1c;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.ghost,
|
||||
.primary {
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Upload as AUpload,
|
||||
} from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
|
||||
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
filterPhoneContacts,
|
||||
isContactPickerSupported,
|
||||
|
||||
@@ -185,6 +185,13 @@ const fabStyle = computed(() => ({
|
||||
bottom: 'auto',
|
||||
}));
|
||||
|
||||
const panelPlacement = computed(() => {
|
||||
const edge = 16;
|
||||
const spaceAbove = fabPos.value.top - edge;
|
||||
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
|
||||
return spaceAbove >= spaceBelow ? 'above' : 'below';
|
||||
});
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
const gap = 12;
|
||||
const edge = 16;
|
||||
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
|
||||
left = Math.min(left, viewport.value.w - width - edge);
|
||||
left = Math.max(edge, left);
|
||||
|
||||
const spaceAbove = fabPos.value.top - edge;
|
||||
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
|
||||
const placeAbove = spaceAbove >= spaceBelow;
|
||||
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap);
|
||||
const placeAbove = panelPlacement.value === 'above';
|
||||
const maxHeight = Math.max(
|
||||
0,
|
||||
(placeAbove
|
||||
? fabPos.value.top - edge
|
||||
: viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge) - gap,
|
||||
);
|
||||
|
||||
const shared = {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
transformOrigin: placeAbove ? 'bottom left' : 'top left',
|
||||
};
|
||||
|
||||
if (placeAbove) {
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
...shared,
|
||||
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
|
||||
top: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
...shared,
|
||||
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
|
||||
bottom: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -238,10 +250,6 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isQuickCreateOpen, (value) => {
|
||||
document.body.style.overflow = value ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(hidden, (value) => {
|
||||
if (value) close();
|
||||
});
|
||||
@@ -264,46 +272,51 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="!hidden" class="qc" dir="rtl">
|
||||
<button
|
||||
v-if="isQuickCreateOpen"
|
||||
class="overlay"
|
||||
type="button"
|
||||
aria-label="بستن ایجاد سریع"
|
||||
@click="close"
|
||||
/>
|
||||
<Transition name="qc-overlay">
|
||||
<button
|
||||
v-if="isQuickCreateOpen"
|
||||
class="overlay"
|
||||
type="button"
|
||||
aria-label="بستن ایجاد سریع"
|
||||
@click="close"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section
|
||||
v-if="isQuickCreateOpen"
|
||||
class="panel"
|
||||
aria-labelledby="qc-title"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<h2 id="qc-title">ایجاد سریع</h2>
|
||||
<div class="items">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="item"
|
||||
type="button"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="item-ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.desc }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="qc-panel">
|
||||
<section
|
||||
v-if="isQuickCreateOpen"
|
||||
class="panel"
|
||||
:data-place="panelPlacement"
|
||||
aria-labelledby="qc-title"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<h2 id="qc-title">ایجاد سریع</h2>
|
||||
<div class="items">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.key"
|
||||
class="item"
|
||||
type="button"
|
||||
:style="{ '--qc-i': index }"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="item-ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.desc }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</Transition>
|
||||
|
||||
<button
|
||||
v-if="ready"
|
||||
@@ -319,6 +332,8 @@ onBeforeUnmount(() => {
|
||||
@pointerup="onFabPointerUp"
|
||||
@pointercancel="onFabPointerUp"
|
||||
>
|
||||
<span class="fab-pulse" aria-hidden="true" />
|
||||
<span class="fab-shine" aria-hidden="true" />
|
||||
<span class="fab-face" aria-hidden="true">
|
||||
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
|
||||
</span>
|
||||
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
|
||||
z-index: 90;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: fixed;
|
||||
z-index: 92;
|
||||
width: 300px;
|
||||
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: 14px;
|
||||
padding: 12px 12px 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow:
|
||||
0 18px 40px -18px rgba(15, 23, 42, 0.35),
|
||||
0 8px 18px -10px rgba(13, 148, 136, 0.18),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.qc-overlay-enter-active,
|
||||
.qc-overlay-leave-active {
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.qc-overlay-enter-from,
|
||||
.qc-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.qc-panel-enter-active {
|
||||
transition:
|
||||
opacity 0.18s var(--qc-ease),
|
||||
transform 0.2s var(--qc-ease);
|
||||
}
|
||||
|
||||
.qc-panel-leave-active {
|
||||
transition:
|
||||
opacity 0.14s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.qc-panel-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
|
||||
.panel[data-place='above'].qc-panel-enter-from {
|
||||
transform: translateY(-8px) scale(0.97);
|
||||
}
|
||||
|
||||
.qc-panel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
|
||||
.panel[data-place='above'].qc-panel-leave-to {
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
|
||||
.qc-panel-enter-active .items {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qc-panel-enter-active .item {
|
||||
animation: qc-item-in 0.2s var(--qc-ease) both;
|
||||
animation-delay: calc(0.02s + var(--qc-i, 0) * 0.02s);
|
||||
}
|
||||
|
||||
.qc-panel-enter-active h2 {
|
||||
animation: qc-item-in 0.16s var(--qc-ease) both;
|
||||
}
|
||||
|
||||
@keyframes qc-item-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qc-overlay-enter-active,
|
||||
.qc-overlay-leave-active,
|
||||
.qc-panel-enter-active,
|
||||
.qc-panel-leave-active,
|
||||
.qc-panel-enter-active .item,
|
||||
.qc-panel-enter-active h2 {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
@@ -369,12 +465,20 @@ onBeforeUnmount(() => {
|
||||
|
||||
.items {
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-left: 2px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.items::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.fab {
|
||||
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: fixed;
|
||||
z-index: 93;
|
||||
box-sizing: border-box;
|
||||
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
|
||||
overflow: visible;
|
||||
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
|
||||
background-size: 140% 140%;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
line-height: 0;
|
||||
direction: ltr;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(13, 148, 136, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
0 0 0 0 rgba(45, 212, 191, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
filter 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
filter 0.35s var(--fab-ease),
|
||||
box-shadow 0.35s var(--fab-ease),
|
||||
transform 0.35s var(--fab-ease),
|
||||
background 0.4s var(--fab-ease);
|
||||
animation:
|
||||
fab-glow 3.4s ease-in-out infinite,
|
||||
fab-gradient 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fab-pulse,
|
||||
.fab-shine,
|
||||
.fab-face {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.fab-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.42);
|
||||
animation: fab-ring 2.8s var(--fab-ease) infinite;
|
||||
}
|
||||
|
||||
.fab-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fab-shine::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
bottom: -20%;
|
||||
left: -40%;
|
||||
width: 42%;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.08) 35%,
|
||||
rgba(255, 255, 255, 0.42) 50%,
|
||||
rgba(255, 255, 255, 0.08) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-160%) skewX(-18deg);
|
||||
animation: fab-sheen 4.8s var(--fab-ease) infinite;
|
||||
}
|
||||
|
||||
.fab-face {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.45s var(--fab-ease);
|
||||
animation: fab-breathe 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fab :deep(.ico) {
|
||||
.fab :deep(.ico),
|
||||
.fab :deep(svg) {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
margin: 0;
|
||||
stroke-width: 1.7;
|
||||
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.18));
|
||||
transition: transform 0.45s var(--fab-ease), opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
filter: brightness(1.06);
|
||||
filter: brightness(1.07) saturate(1.05);
|
||||
transform: translateY(-1px) scale(1.04);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(13, 148, 136, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
0 10px 24px rgba(13, 148, 136, 0.42),
|
||||
0 0 0 5px rgba(45, 212, 191, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.fab:hover .fab-face {
|
||||
animation-play-state: paused;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.fab.open {
|
||||
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
|
||||
box-shadow:
|
||||
0 8px 22px rgba(15, 23, 42, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.fab.open .fab-pulse,
|
||||
.fab.open .fab-shine::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fab.open .fab-face {
|
||||
animation: none;
|
||||
transform: rotate(90deg) scale(1.02);
|
||||
}
|
||||
|
||||
.fab.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
|
||||
transform: scale(1.08);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(13, 148, 136, 0.48),
|
||||
0 0 0 6px rgba(45, 212, 191, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
animation: none;
|
||||
transition-duration: 0.12s;
|
||||
}
|
||||
|
||||
.fab.dragging .fab-pulse,
|
||||
.fab.dragging .fab-shine::after,
|
||||
.fab.dragging .fab-face {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes fab-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 4px 14px rgba(13, 148, 136, 0.32),
|
||||
0 0 0 0 rgba(45, 212, 191, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 8px 22px rgba(13, 148, 136, 0.48),
|
||||
0 0 18px 2px rgba(45, 212, 191, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-gradient {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 40%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 12px rgba(45, 212, 191, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-sheen {
|
||||
0%,
|
||||
55% {
|
||||
transform: translateX(-160%) skewX(-18deg);
|
||||
}
|
||||
85%,
|
||||
100% {
|
||||
transform: translateX(320%) skewX(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab,
|
||||
.fab-pulse,
|
||||
.fab-shine::after,
|
||||
.fab-face {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.fab:hover,
|
||||
.fab.open .fab-face {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { OrgRoleDto, RolePermissionKey } from '@mento/shared';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Space as ASpace,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { UserOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
|
||||
|
||||
const props = defineProps<{
|
||||
role: OrgRoleDto;
|
||||
childRoles: OrgRoleDto[];
|
||||
permissionItems: Array<{ key: RolePermissionKey; label: string }>;
|
||||
avatarOf: (id: string) => string;
|
||||
tagColor: (color: string | null | undefined) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'pick-role': [id: string];
|
||||
}>();
|
||||
|
||||
const innerTab = ref('summary');
|
||||
|
||||
watch(
|
||||
() => props.role.id,
|
||||
() => {
|
||||
innerTab.value = 'summary';
|
||||
},
|
||||
);
|
||||
|
||||
const grantedPermissions = computed(() =>
|
||||
props.permissionItems.filter((item) => props.role.permissions.includes(item.key)),
|
||||
);
|
||||
|
||||
const permSummary = computed(
|
||||
() =>
|
||||
`${toFaDigits(grantedPermissions.value.length)} از ${toFaDigits(props.permissionItems.length)}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-space direction="vertical" :size="4">
|
||||
<a-space wrap>
|
||||
<a-tag v-if="role.systemKey" :bordered="false">سیستمی</a-tag>
|
||||
<a-typography-text v-if="role.parentName" type="secondary">
|
||||
زیرمجموعهٔ
|
||||
<a-typography-link @click="emit('pick-role', role.parentId!)">
|
||||
{{ role.parentName }}
|
||||
</a-typography-link>
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text v-if="role.description" type="secondary">
|
||||
{{ role.description }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
|
||||
<a-tabs v-model:activeKey="innerTab">
|
||||
<a-tab-pane key="summary" tab="خلاصه">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="کاربران">
|
||||
{{ toFaDigits(role.userCount) }} نفر
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="مجوزها">
|
||||
{{ permSummary }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="زیرنقشها">
|
||||
{{ toFaDigits(childRoles.length) }} مورد
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text strong>زیرنقشها</a-typography-text>
|
||||
<a-space v-if="childRoles.length" wrap>
|
||||
<a-tag
|
||||
v-for="child in childRoles"
|
||||
:key="child.id"
|
||||
:color="tagColor(child.color)"
|
||||
:bordered="false"
|
||||
style="cursor: pointer"
|
||||
@click="emit('pick-role', child.id)"
|
||||
>
|
||||
{{ child.name }} · {{ toFaDigits(child.userCount) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-empty v-else description="زیرنقشی تعریف نشده" :image="emptySimple" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="perms" :tab="`مجوزها (${permSummary})`">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
فقط مجوزهای فعال نمایش داده میشود. برای تغییر، ویرایش نقش را بزنید.
|
||||
</a-typography-text>
|
||||
<a-space v-if="grantedPermissions.length" wrap>
|
||||
<a-tag
|
||||
v-for="perm in grantedPermissions"
|
||||
:key="perm.key"
|
||||
color="success"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ perm.label }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-empty v-else description="هیچ مجوزی فعال نیست" :image="emptySimple" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="members" :tab="`کاربران (${toFaDigits(role.userCount)})`">
|
||||
<a-list v-if="role.members.length" :data-source="role.members">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="item.fullName">
|
||||
<template #avatar>
|
||||
<a-avatar :src="avatarOf(item.id)">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-empty v-else description="کاربری با این نقش نیست" :image="emptySimple" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-flex>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
|
||||
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Popover as APopover,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { SettingOutlined } from '@ant-design/icons-vue';
|
||||
import { SettingOutlined } from '@/icons/ant-bridge';
|
||||
import { useTableColumns } from '@/lib/tableColumns';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TeamDto,
|
||||
TeamMemberRole,
|
||||
UpdatePriorArchiveCutoffRequest,
|
||||
UserTreasuryAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
@@ -18,9 +19,36 @@ import {
|
||||
emptySectionDataAccess,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Collapse as ACollapse,
|
||||
CollapsePanel as ACollapsePanel,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Radio as ARadio,
|
||||
RadioGroup as ARadioGroup,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
users: OrgUserDto[];
|
||||
@@ -36,7 +64,7 @@ const showPriorArchive = computed(
|
||||
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
|
||||
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
|
||||
const priorSearch = ref('');
|
||||
const priorPick = ref('');
|
||||
const priorPick = ref<string | undefined>(undefined);
|
||||
const priorSavingId = ref('');
|
||||
const cutoffLocal = ref('');
|
||||
const cutoffSaving = ref(false);
|
||||
@@ -47,10 +75,16 @@ const availablePriorUsers = computed(() => {
|
||||
return priorUsers.value.filter((u) => {
|
||||
if (u.priorArchiveEnabled) return false;
|
||||
if (!q) return true;
|
||||
return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
|
||||
return [u.fullName, u.email, u.mobile]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
const availablePriorOptions = computed(() =>
|
||||
availablePriorUsers.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshUsers: [];
|
||||
}>();
|
||||
@@ -73,9 +107,14 @@ const editableUsers = computed(() =>
|
||||
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
||||
);
|
||||
|
||||
const memberRoleOptions = [
|
||||
{ value: 'member', label: 'عضو' },
|
||||
{ value: 'manager', label: 'سرپرست' },
|
||||
];
|
||||
|
||||
async function setPriorArchive(userId: string, enabled: boolean) {
|
||||
priorSavingId.value = userId;
|
||||
priorPick.value = '';
|
||||
priorPick.value = undefined;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
@@ -113,7 +152,10 @@ async function loadPriorArchiveCutoff() {
|
||||
applyCutoffFromOrg(data);
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'بارگذاری تاریخ بایگانی ناموفق بود.',
|
||||
)
|
||||
: 'بارگذاری تاریخ بایگانی ناموفق بود.';
|
||||
} finally {
|
||||
cutoffLoading.value = false;
|
||||
@@ -135,7 +177,10 @@ async function onCutoffChange(value: string) {
|
||||
await auth.hydrate();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'ذخیره تاریخ بایگانی ناموفق بود.',
|
||||
)
|
||||
: 'ذخیره تاریخ بایگانی ناموفق بود.';
|
||||
await loadPriorArchiveCutoff();
|
||||
} finally {
|
||||
@@ -143,9 +188,10 @@ async function onCutoffChange(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function pickPriorUser(userId: string) {
|
||||
if (!userId) return;
|
||||
void setPriorArchive(userId, true);
|
||||
function pickPriorUser(userId: unknown) {
|
||||
const id = userId == null ? '' : String(userId);
|
||||
if (!id) return;
|
||||
void setPriorArchive(id, true);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -242,9 +288,19 @@ function toggleMember(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function setRole(userId: string, role: TeamMemberRole) {
|
||||
function setRole(userId: string, role: unknown) {
|
||||
if (!memberDraft.value[userId]) return;
|
||||
memberDraft.value = { ...memberDraft.value, [userId]: role };
|
||||
memberDraft.value = {
|
||||
...memberDraft.value,
|
||||
[userId]: String(role) as TeamMemberRole,
|
||||
};
|
||||
}
|
||||
|
||||
function teamMembersLabel(team: TeamDto) {
|
||||
if (!team.members.length) return 'بدون عضو';
|
||||
return team.members
|
||||
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
|
||||
.join('، ');
|
||||
}
|
||||
|
||||
async function saveMembers() {
|
||||
@@ -279,8 +335,13 @@ async function removeTeam(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const showTreasury = computed(() => {
|
||||
const modules = auth.user?.enabledModules;
|
||||
return !modules || modules.includes('treasury');
|
||||
});
|
||||
const expandedAccess = ref<string | null>(null);
|
||||
const draftAccess = ref<DataAccessPolicy>({});
|
||||
const draftTreasury = ref<UserTreasuryAccess | null>(null);
|
||||
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
||||
const accessSaving = ref(false);
|
||||
|
||||
@@ -323,6 +384,7 @@ function openAccessEditor(u: OrgUserDto) {
|
||||
draftAccess.value = hasOverride
|
||||
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
||||
: clonePolicy(u.effectiveDataAccess);
|
||||
draftTreasury.value = u.treasuryAccessOverride ?? null;
|
||||
expandedAccess.value = u.id;
|
||||
}
|
||||
|
||||
@@ -333,8 +395,8 @@ async function saveUserAccess(userId: string) {
|
||||
try {
|
||||
const payload =
|
||||
accessMode.value === 'inherit'
|
||||
? { dataAccessOverride: null }
|
||||
: { dataAccessOverride: draftAccess.value };
|
||||
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
|
||||
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
|
||||
await api.patch(`/users/${userId}/data-access`, payload);
|
||||
ok.value = 'محدوده داده بهروز شد.';
|
||||
expandedAccess.value = null;
|
||||
@@ -358,360 +420,226 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="teams-panel">
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-flex vertical :gap="12">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<section v-if="canEdit" class="card">
|
||||
<h3>تیم جدید</h3>
|
||||
<div class="row">
|
||||
<UiField label="نام تیم">
|
||||
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="newDesc" placeholder="اختیاری" />
|
||||
</UiField>
|
||||
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
<a-card :bordered="false">
|
||||
<a-form v-if="canEdit" layout="vertical" @finish="createTeam">
|
||||
<a-divider orientation="right">تیم جدید</a-divider>
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نام تیم">
|
||||
<a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="10">
|
||||
<a-form-item label="توضیح">
|
||||
<a-input v-model:value="newDesc" placeholder="اختیاری" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" :loading="saving" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<section class="card">
|
||||
<h3>تیمها</h3>
|
||||
<UiEmptyState
|
||||
v-if="!loading && !teams.length"
|
||||
title="تیمی تعریف نشده"
|
||||
description="تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
/>
|
||||
<div v-else class="team-list">
|
||||
<article v-for="t in teams" :key="t.id" class="team">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ t.name }}</strong>
|
||||
<p class="muted">
|
||||
{{ t.description || '—' }}
|
||||
· {{ toFaDigits(t.memberCount) }} عضو
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="canEdit" class="actions">
|
||||
<UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
|
||||
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
|
||||
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
|
||||
</div>
|
||||
</header>
|
||||
<p class="members">
|
||||
{{
|
||||
t.members.length
|
||||
? t.members
|
||||
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
|
||||
.join('، ')
|
||||
: 'بدون عضو'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="editingMetaId === t.id" class="editor">
|
||||
<div class="row">
|
||||
<UiField label="نام تیم">
|
||||
<UiInput v-model="editName" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="editDesc" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
|
||||
<UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editingId === t.id" class="editor">
|
||||
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
|
||||
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="Boolean(memberDraft[u.id])"
|
||||
@change="toggleMember(u.id)"
|
||||
/>
|
||||
<span>{{ u.fullName }}</span>
|
||||
<select
|
||||
v-if="memberDraft[u.id]"
|
||||
:value="memberDraft[u.id]"
|
||||
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
|
||||
>
|
||||
<option value="member">عضو</option>
|
||||
<option value="manager">سرپرست</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
|
||||
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showPriorArchive" class="card">
|
||||
<header class="prior-head">
|
||||
<h3>بایگانی اطلاعات قبل</h3>
|
||||
<JalaliDateInput
|
||||
:model-value="cutoffLocal"
|
||||
mode="date"
|
||||
placeholder="تاریخ برش"
|
||||
:disabled="cutoffSaving || cutoffLoading"
|
||||
@update:model-value="onCutoffChange"
|
||||
<a-divider orientation="right">تیمها</a-divider>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="6">
|
||||
<a-empty
|
||||
v-if="!loading && !teams.length"
|
||||
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
/>
|
||||
</header>
|
||||
<p class="hint">
|
||||
فقط کاربرانی که تیک میخورند سوئیچ کار جاری و بایگانی را میبینند. بقیهٔ محیط مثل قبل همهٔ
|
||||
رکوردها را مطابق سطح دسترسی خودشان میبینند.
|
||||
</p>
|
||||
<div class="prior-picker">
|
||||
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
|
||||
<select
|
||||
v-model="priorPick"
|
||||
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
|
||||
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">
|
||||
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعالسازی' : 'کاربر دیگری پیدا نشد' }}
|
||||
</option>
|
||||
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedPriorUsers.length" class="prior-chips">
|
||||
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
|
||||
{{ u.fullName }}
|
||||
<button
|
||||
type="button"
|
||||
:disabled="priorSavingId === u.id"
|
||||
:aria-label="`حذف ${u.fullName}`"
|
||||
@click="setPriorArchive(u.id, false)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
|
||||
</section>
|
||||
<a-list v-else item-layout="vertical" :data-source="teams">
|
||||
<template #renderItem="{ item: t }">
|
||||
<a-list-item>
|
||||
<template v-if="canEdit" #actions>
|
||||
<a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
|
||||
<a-button type="link" @click="startEdit(t)">اعضا</a-button>
|
||||
<a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ t.name }}</a-typography-text>
|
||||
<a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<a-typography-text type="secondary">
|
||||
{{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
|
||||
<section class="card">
|
||||
<h3>محدوده داده</h3>
|
||||
<p class="hint">
|
||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
||||
جدا بگذارید.
|
||||
</p>
|
||||
<UiEmptyState
|
||||
v-if="!editableUsers.length"
|
||||
title="کاربر قابل تنظیم نیست"
|
||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||
/>
|
||||
<div v-else class="vis-table">
|
||||
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
|
||||
<div class="vis-row">
|
||||
<span>
|
||||
{{ u.fullName }}
|
||||
<small class="vis-badge">{{ accessSummary(u) }}</small>
|
||||
</span>
|
||||
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
|
||||
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="expandedAccess === u.id" class="vis-scopes">
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="inherit" />
|
||||
مثل نقش (بدون override)
|
||||
</label>
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="custom" />
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<DataAccessPolicyPanel
|
||||
v-if="accessMode === 'custom'"
|
||||
v-model="draftAccess"
|
||||
compact
|
||||
<a-form
|
||||
v-if="editingMetaId === t.id"
|
||||
layout="vertical"
|
||||
style="width: 100%"
|
||||
@finish="saveMeta"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام تیم">
|
||||
<a-input v-model:value="editName" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="توضیح">
|
||||
<a-input v-model:value="editDesc" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
|
||||
<a-button @click="editingMetaId = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
|
||||
<a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
|
||||
<a-typography-text type="secondary">
|
||||
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
|
||||
</a-typography-text>
|
||||
<a-row :gutter="[12, 8]">
|
||||
<a-col
|
||||
v-for="u in users.filter((x) => x.isActive !== false)"
|
||||
:key="u.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:lg="8"
|
||||
>
|
||||
<a-space>
|
||||
<a-checkbox
|
||||
:checked="Boolean(memberDraft[u.id])"
|
||||
@change="toggleMember(u.id)"
|
||||
>
|
||||
{{ u.fullName }}
|
||||
</a-checkbox>
|
||||
<a-select
|
||||
v-if="memberDraft[u.id]"
|
||||
:value="memberDraft[u.id]"
|
||||
:options="memberRoleOptions"
|
||||
style="width: 120px"
|
||||
@update:value="setRole(u.id, $event)"
|
||||
/>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" @click="saveMembers">ذخیره اعضا</a-button>
|
||||
<a-button @click="editingId = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-collapse :bordered="false" ghost>
|
||||
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل">
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :lg="8">
|
||||
<JalaliDateInput
|
||||
:model-value="cutoffLocal"
|
||||
mode="date"
|
||||
placeholder="تاریخ برش"
|
||||
:disabled="cutoffSaving || cutoffLoading"
|
||||
@update:model-value="onCutoffChange"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-input v-model:value="priorSearch" placeholder="جستجوی کاربر…" style="width: 100%" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-select
|
||||
:value="priorPick"
|
||||
:options="availablePriorOptions"
|
||||
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:placeholder="
|
||||
availablePriorUsers.length ? 'فعالسازی کاربر' : 'کاربر دیگری پیدا نشد'
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="pickPriorUser"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
فقط این کاربران سوئیچ کار جاری / بایگانی را میبینند.
|
||||
</a-typography-text>
|
||||
<a-space v-if="selectedPriorUsers.length" wrap>
|
||||
<a-tag
|
||||
v-for="u in selectedPriorUsers"
|
||||
:key="u.id"
|
||||
closable
|
||||
:disabled="priorSavingId === u.id"
|
||||
@close.prevent="setPriorArchive(u.id, false)"
|
||||
>
|
||||
{{ u.fullName }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-typography-text v-else type="secondary">
|
||||
هنوز کاربری انتخاب نشده است.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="access" header="محدوده داده کاربران">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر جدا بگذارید.
|
||||
</a-typography-text>
|
||||
<a-empty
|
||||
v-if="!editableUsers.length"
|
||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||
/>
|
||||
<div class="actions">
|
||||
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
|
||||
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-list v-else item-layout="vertical" :data-source="editableUsers">
|
||||
<template #renderItem="{ item: u }">
|
||||
<a-list-item>
|
||||
<template v-if="canEdit" #actions>
|
||||
<a-button type="link" @click="openAccessEditor(u)">
|
||||
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta :description="accessSummary(u)">
|
||||
<template #title>
|
||||
<a-typography-text strong>{{ u.fullName }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%">
|
||||
<a-radio-group v-model:value="accessMode">
|
||||
<a-space wrap>
|
||||
<a-radio value="inherit">مثل نقش</a-radio>
|
||||
<a-radio value="custom">سفارشی</a-radio>
|
||||
</a-space>
|
||||
</a-radio-group>
|
||||
<DataAccessPolicyPanel
|
||||
v-model="draftAccess"
|
||||
v-model:treasury-access="draftTreasury"
|
||||
:show-treasury="showTreasury"
|
||||
:data-locked="accessMode === 'inherit'"
|
||||
compact
|
||||
/>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)">
|
||||
ذخیره
|
||||
</a-button>
|
||||
<a-button @click="expandedAccess = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teams-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius, 12px);
|
||||
padding: 1rem;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.prior-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.prior-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.prior-head :deep(.trigger) {
|
||||
min-width: 12rem;
|
||||
}
|
||||
.prior-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.prior-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.prior-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #ccfbf1;
|
||||
border-radius: 999px;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prior-chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.prior-chip button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
.team-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.team {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius, 12px);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.team header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.muted,
|
||||
.hint,
|
||||
.members {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.editor {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.person {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.vis-table {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.vis-user {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
.vis-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
.vis-badge {
|
||||
margin-inline-start: 0.4rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.vis-scopes {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.chip-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
select {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #fff;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.row,
|
||||
.vis-row,
|
||||
.prior-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* آیکونهای legacy — حالا از پک vuesax-linear رندر میشوند.
|
||||
* API نامها برای سازگاری با صفحات قدیمی حفظ شده.
|
||||
*/
|
||||
import VsIcon from '@/components/VsIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
name:
|
||||
| 'home'
|
||||
@@ -82,352 +88,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="ico"
|
||||
:width="size ?? 20"
|
||||
:height="size ?? 20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-if="name === 'home'">
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5 9.5V21h14V9.5" />
|
||||
<path d="M10 21v-6h4v6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'users'">
|
||||
<circle cx="9" cy="8" r="3.2" />
|
||||
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
|
||||
<circle cx="17" cy="9" r="2.4" />
|
||||
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'briefcase'">
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
|
||||
<path d="M3 12h18" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'box'">
|
||||
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
|
||||
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chart'">
|
||||
<path d="M4 19h16" />
|
||||
<path d="M7 16V10" />
|
||||
<path d="M12 16V6" />
|
||||
<path d="M17 16v-4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'settings'">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'search'">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'sparkle'">
|
||||
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
|
||||
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
|
||||
<circle cx="12" cy="12" r="2.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'bell'">
|
||||
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
|
||||
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-plus'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<path d="M12 13v5M9.5 15.5h5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-sun'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<circle cx="12" cy="15.2" r="2.1" />
|
||||
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-edit'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'mail'">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m4 7 8 6 8-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'megaphone'">
|
||||
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
|
||||
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
|
||||
<path d="M10 15v3a2 2 0 0 1-2 2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'send'">
|
||||
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'fire'">
|
||||
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'warning'">
|
||||
<path d="m12 4 9 16H3L12 4Z" />
|
||||
<path d="M12 10v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trend'">
|
||||
<path d="M4 17 10 11l4 4 6-8" />
|
||||
<path d="M15 7h5v5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'phone'">
|
||||
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trophy'">
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
|
||||
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
|
||||
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chat'">
|
||||
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
|
||||
<path d="M8.5 11h7M8.5 14h4.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dots'">
|
||||
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dots-v'">
|
||||
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'document'">
|
||||
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
|
||||
<path d="M14.5 4.5V9H19" />
|
||||
<path d="M9 13h6M9 16.5h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'download'">
|
||||
<path d="M12 4.5v9" />
|
||||
<path d="M8.5 10.5 12 14l3.5-3.5" />
|
||||
<path d="M6 17.5h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'upload'">
|
||||
<path d="M12 14.5v-9" />
|
||||
<path d="M8.5 8.5 12 5l3.5 3.5" />
|
||||
<path d="M6 17.5h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'bag'">
|
||||
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
|
||||
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'clock'">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 8v4.2l2.8 1.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'user-plus'">
|
||||
<circle cx="10" cy="8" r="3" />
|
||||
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
|
||||
<path d="M17 8v5M14.5 10.5h5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'plus'">
|
||||
<path d="M12 6v12M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron-left'">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-right'">
|
||||
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-left'">
|
||||
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-right'">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-left'">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'star'">
|
||||
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pin'">
|
||||
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
|
||||
<path d="M12 20v-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'filter'">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'funnel'">
|
||||
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lightbulb'">
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 21h4" />
|
||||
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'building'">
|
||||
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
|
||||
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
|
||||
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
|
||||
<path d="M4 21h16" />
|
||||
</template>
|
||||
<template v-else-if="name === 'user'">
|
||||
<circle cx="12" cy="8" r="3.2" />
|
||||
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'flag'">
|
||||
<path d="M6 21V4" />
|
||||
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'refresh'">
|
||||
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'x'">
|
||||
<path d="M6 6 18 18M18 6 6 18" />
|
||||
</template>
|
||||
<template v-else-if="name === 'x-circle'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6M15 9l-6 6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'mic'">
|
||||
<rect x="9" y="3.5" width="6" height="10" rx="3" />
|
||||
<path d="M7 11.5a5 5 0 0 0 10 0" />
|
||||
<path d="M12 16.5V20M9 20h6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trash'">
|
||||
<path d="M5 7h14" />
|
||||
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
|
||||
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'stop'">
|
||||
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'minus'">
|
||||
<path d="M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dollar'">
|
||||
<circle cx="12" cy="12" r="8.4" />
|
||||
<path d="M12 6.8v10.4" />
|
||||
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check-plain'">
|
||||
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'layers'">
|
||||
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
|
||||
<path d="M4 12.2 12 16.4 20 12.2" />
|
||||
<path d="M4 16.2 12 20.4 20 16.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'target'">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle cx="12" cy="12" r="4.2" />
|
||||
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pencil'">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'edit'">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grid'">
|
||||
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'list'">
|
||||
<path d="M8 6h12M8 12h12M8 18h12" />
|
||||
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye'">
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye-off'">
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M4 20 20 4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lock'">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'truck'">
|
||||
<path d="M3 7.5h11V16H3z" />
|
||||
<path d="M14 10h4.2l2.3 3.2V16H14z" />
|
||||
<circle cx="7" cy="17.5" r="1.6" />
|
||||
<circle cx="17" cy="17.5" r="1.6" />
|
||||
<path d="M3 16h1.2M8.7 16H15" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grip'">
|
||||
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'apps-plus'">
|
||||
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
|
||||
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
|
||||
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
|
||||
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'crown'">
|
||||
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
|
||||
<path d="M5.2 18.6h13.6" />
|
||||
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'gift'">
|
||||
<rect x="4" y="11" width="16" height="9" rx="1.4" />
|
||||
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
|
||||
<path d="M12 6.8v13.2" />
|
||||
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
|
||||
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'medal'">
|
||||
<circle cx="12" cy="9" r="5.2" />
|
||||
<circle cx="12" cy="9" r="2.3" />
|
||||
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'logout'">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pulse'">
|
||||
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
|
||||
</template>
|
||||
<template v-else-if="name === 'sliders'">
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<path d="M8 4.5v5M16 14.5v5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'camera'">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'clip'">
|
||||
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
|
||||
</template>
|
||||
</svg>
|
||||
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { Button as AButton, Typography } from 'ant-design-vue';
|
||||
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getVuesaxSvg } from '@/icons/vuesax';
|
||||
import { resolveVuesaxName } from '@/icons/name-map';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** کلید کوتاه vuesax (مثل add) یا نام UiIcon / Ant با map */
|
||||
name: string;
|
||||
size?: number | string;
|
||||
/** منبع نگاشت نام؛ پیشفرض raw = کلید مستقیم پک */
|
||||
map?: 'raw' | 'ui' | 'ant';
|
||||
spin?: boolean;
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
size: 20,
|
||||
map: 'raw',
|
||||
spin: false,
|
||||
},
|
||||
);
|
||||
|
||||
const resolved = computed(() => resolveVuesaxName(props.name, props.map));
|
||||
|
||||
const markup = computed(() => {
|
||||
const svg = getVuesaxSvg(resolved.value);
|
||||
if (!svg) return '';
|
||||
// Strip fixed width/height so CSS size controls the box; keep viewBox.
|
||||
return svg
|
||||
.replace(/\swidth="[^"]*"/, '')
|
||||
.replace(/\sheight="[^"]*"/, '')
|
||||
.replace(
|
||||
'<svg ',
|
||||
`<svg width="${props.size}" height="${props.size}" style="width:${typeof props.size === 'number' ? props.size + 'px' : props.size};height:${typeof props.size === 'number' ? props.size + 'px' : props.size}" `,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="vs-icon"
|
||||
:class="{ 'vs-icon--spin': spin }"
|
||||
:style="{
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}"
|
||||
:title="title"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
v-html="markup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vs-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.vs-icon :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.vs-icon--spin {
|
||||
animation: vs-icon-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes vs-icon-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,9 +10,10 @@ import type {
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import { Spin as ASpin } from 'ant-design-vue';
|
||||
import { Flex as AFlex } from 'ant-design-vue';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import {
|
||||
ACTIVITY_BOARD_COPY,
|
||||
initialsFromTitle,
|
||||
@@ -121,6 +122,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
assigneeNames,
|
||||
ownerId: item.assignee?.id ?? null,
|
||||
assigneeIds: (item.assignees ?? []).map((a) => a.id),
|
||||
priority: item.priority,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,40 +256,54 @@ function onDragOver(stageId: string, event: DragEvent) {
|
||||
|
||||
async function moveCard(activityId: string, toStageId: string, index?: number) {
|
||||
const fromStageId = findStageOf(activityId);
|
||||
if (!fromStageId || fromStageId === toStageId) return;
|
||||
if (!fromStageId) return;
|
||||
const from = columns.value[fromStageId]!;
|
||||
const to = columns.value[toStageId];
|
||||
if (!to) return;
|
||||
const item = from.items.find((i) => i.id === activityId)!;
|
||||
const fromIndex = from.items.findIndex((i) => i.id === activityId);
|
||||
const item = from.items[fromIndex];
|
||||
if (!item) return;
|
||||
const toStage = props.stages.find((s) => s.id === toStageId)!;
|
||||
const same = fromStageId === toStageId;
|
||||
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
|
||||
if (same && index == null) return;
|
||||
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;
|
||||
|
||||
const snapshot = {
|
||||
fromItems: from.items,
|
||||
fromTotal: from.total,
|
||||
toItems: to.items,
|
||||
toTotal: to.total,
|
||||
};
|
||||
const prevFrom = from.items;
|
||||
const prevTo = to.items;
|
||||
const prevFromTotal = from.total;
|
||||
const prevToTotal = to.total;
|
||||
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
|
||||
from.items = from.items.filter((i) => i.id !== activityId);
|
||||
from.total = Math.max(0, from.total - 1);
|
||||
const nextTo = [...to.items];
|
||||
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
|
||||
nextTo.splice(at, 0, optimistic);
|
||||
to.items = nextTo;
|
||||
to.total += 1;
|
||||
const nextTo = [...without];
|
||||
nextTo.splice(insertAt, 0, optimistic);
|
||||
if (same) {
|
||||
from.items = nextTo;
|
||||
} else {
|
||||
from.items = from.items.filter((i) => i.id !== activityId);
|
||||
from.total = Math.max(0, from.total - 1);
|
||||
to.items = nextTo;
|
||||
to.total += 1;
|
||||
}
|
||||
|
||||
moving.value = true;
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
|
||||
stageId: toStageId,
|
||||
position: insertAt,
|
||||
});
|
||||
to.items = to.items.map((i) => (i.id === activityId ? data : i));
|
||||
const column = same ? from : to;
|
||||
column.items = column.items.map((i) => (i.id === activityId ? data : i));
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
from.items = snapshot.fromItems;
|
||||
from.total = snapshot.fromTotal;
|
||||
to.items = snapshot.toItems;
|
||||
to.total = snapshot.toTotal;
|
||||
from.items = prevFrom;
|
||||
from.total = prevFromTotal;
|
||||
if (!same) {
|
||||
to.items = prevTo;
|
||||
to.total = prevToTotal;
|
||||
}
|
||||
emit('error', apiMessage(e, 'جابهجایی فعالیت ناموفق بود.'));
|
||||
} finally {
|
||||
moving.value = false;
|
||||
@@ -426,36 +442,47 @@ function openActivity(id: string) {
|
||||
flexDirection: 'column',
|
||||
}"
|
||||
>
|
||||
<a-spin
|
||||
v-if="initialLoading && !Object.keys(columns).length"
|
||||
tip="در حال بارگذاری ستونها…"
|
||||
/>
|
||||
<PipelineBoard
|
||||
v-else
|
||||
:stages="boardStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canManage"
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
|
||||
<a-flex
|
||||
v-if="initialLoading && !Object.keys(columns).length"
|
||||
:gap="12"
|
||||
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
|
||||
>
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="`sk-col-${i}`"
|
||||
:style="{ width: '280px', flex: '0 0 280px' }"
|
||||
>
|
||||
<CrmSkeletonCard :rows="6" :min-height="360" />
|
||||
</div>
|
||||
</a-flex>
|
||||
<PipelineBoard
|
||||
v-else
|
||||
:stages="boardStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canManage"
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
</CrmSkeleton>
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
PlusOutlined,
|
||||
UpOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
ContainerOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, type Component } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
|
||||
import AutomationNode from '@/components/automations/AutomationNode.vue';
|
||||
import {
|
||||
AUTOMATION_ACTION_LABELS,
|
||||
@@ -29,13 +29,15 @@ import {
|
||||
ExpandOutlined,
|
||||
FilterOutlined,
|
||||
FlagOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GiftOutlined,
|
||||
PlusOutlined,
|
||||
SelectOutlined,
|
||||
ThunderboltOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
export type AutomationAddPayload =
|
||||
| { kind: 'action'; action: AutomationAction }
|
||||
@@ -56,8 +58,15 @@ const props = withDefaults(
|
||||
form?: LeadFormDto | null;
|
||||
users?: OrgUserDto[];
|
||||
teams?: TeamDto[];
|
||||
fullscreen?: boolean;
|
||||
}>(),
|
||||
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
|
||||
{
|
||||
chainMode: 'stop',
|
||||
form: null,
|
||||
users: () => [],
|
||||
teams: () => [],
|
||||
fullscreen: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -66,6 +75,7 @@ const emit = defineEmits<{
|
||||
remove: [id: string];
|
||||
removeRange: [start: number, end: number];
|
||||
addBranch: [];
|
||||
'update:fullscreen': [value: boolean];
|
||||
}>();
|
||||
|
||||
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
|
||||
@@ -169,6 +179,21 @@ function onViewportClick() {
|
||||
addAt.value = null;
|
||||
}
|
||||
|
||||
function onDocPointerDown(e: PointerEvent) {
|
||||
if (!addAt.value) return;
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.picker') || target.closest('.plus')) return;
|
||||
addAt.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocPointerDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
||||
});
|
||||
|
||||
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
|
||||
const steps = props.steps;
|
||||
const { prefixEnd, branches } = splitAutomationBranches(steps);
|
||||
@@ -395,42 +420,44 @@ function removeBranch(lane: BranchLane) {
|
||||
>
|
||||
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
|
||||
</button>
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="picker-head">افزودن گره</div>
|
||||
<button
|
||||
v-if="addAt.allow !== 'action'"
|
||||
type="button"
|
||||
class="pick condition"
|
||||
@click="pickCondition"
|
||||
<Transition name="auto-picker">
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span class="pick-ico"><FilterOutlined /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>شرط</strong>
|
||||
<small>اگر … آنگاه</small>
|
||||
</span>
|
||||
</button>
|
||||
<template v-if="addAt.allow !== 'condition'">
|
||||
<div class="picker-head">افزودن گره</div>
|
||||
<button
|
||||
v-for="action in AUTOMATION_ACTIONS"
|
||||
:key="action"
|
||||
v-if="addAt.allow !== 'action'"
|
||||
type="button"
|
||||
class="pick"
|
||||
:class="action"
|
||||
@click="pickAction(action)"
|
||||
class="pick condition"
|
||||
@click="pickCondition"
|
||||
>
|
||||
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
|
||||
<span class="pick-ico"><FilterOutlined /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>{{ actionShort(action) }}</strong>
|
||||
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
|
||||
<strong>شرط</strong>
|
||||
<small>اگر … آنگاه</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="addAt.allow !== 'condition'">
|
||||
<button
|
||||
v-for="action in AUTOMATION_ACTIONS"
|
||||
:key="action"
|
||||
type="button"
|
||||
class="pick"
|
||||
:class="action"
|
||||
@click="pickAction(action)"
|
||||
>
|
||||
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>{{ actionShort(action) }}</strong>
|
||||
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -474,42 +501,44 @@ function removeBranch(lane: BranchLane) {
|
||||
>
|
||||
<PlusOutlined :style="{ fontSize: '14px' }" />
|
||||
</button>
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="picker-head">افزودن گره</div>
|
||||
<button
|
||||
v-if="addAt.allow !== 'action'"
|
||||
type="button"
|
||||
class="pick condition"
|
||||
@click="pickCondition"
|
||||
<Transition name="auto-picker">
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span class="pick-ico"><FilterOutlined /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>شرط</strong>
|
||||
<small>اگر … آنگاه</small>
|
||||
</span>
|
||||
</button>
|
||||
<template v-if="addAt.allow !== 'condition'">
|
||||
<div class="picker-head">افزودن گره</div>
|
||||
<button
|
||||
v-for="action in AUTOMATION_ACTIONS"
|
||||
:key="action"
|
||||
v-if="addAt.allow !== 'action'"
|
||||
type="button"
|
||||
class="pick"
|
||||
:class="action"
|
||||
@click="pickAction(action)"
|
||||
class="pick condition"
|
||||
@click="pickCondition"
|
||||
>
|
||||
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
|
||||
<span class="pick-ico"><FilterOutlined /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>{{ actionShort(action) }}</strong>
|
||||
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
|
||||
<strong>شرط</strong>
|
||||
<small>اگر … آنگاه</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="addAt.allow !== 'condition'">
|
||||
<button
|
||||
v-for="action in AUTOMATION_ACTIONS"
|
||||
:key="action"
|
||||
type="button"
|
||||
class="pick"
|
||||
:class="action"
|
||||
@click="pickAction(action)"
|
||||
>
|
||||
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
|
||||
<span class="pick-copy">
|
||||
<strong>{{ actionShort(action) }}</strong>
|
||||
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -582,6 +611,19 @@ function removeBranch(lane: BranchLane) {
|
||||
<template #icon><ExpandOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
|
||||
<a-tooltip :title="fullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه'">
|
||||
<a-button
|
||||
type="text"
|
||||
:class="{ on: fullscreen }"
|
||||
@click="emit('update:fullscreen', !fullscreen)"
|
||||
>
|
||||
<template #icon>
|
||||
<FullscreenExitOutlined v-if="fullscreen" />
|
||||
<FullscreenOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -892,6 +934,28 @@ strong {
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.auto-picker-enter-active,
|
||||
.auto-picker-leave-active {
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: top center;
|
||||
will-change: opacity, transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auto-picker-enter-from,
|
||||
.auto-picker-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(6px) scale(0.97);
|
||||
}
|
||||
|
||||
.auto-picker-enter-to,
|
||||
.auto-picker-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FilterOutlined,
|
||||
FlagOutlined,
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { RobotOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
@@ -15,12 +17,15 @@ import {
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
import { LinkOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const props = defineProps<{
|
||||
askQuery: string;
|
||||
@@ -38,11 +43,41 @@ const emit = defineEmits<{
|
||||
notice: [value: { error: string; done: string }];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const AFormItem = AForm.Item;
|
||||
const ATextarea = AInput.TextArea;
|
||||
|
||||
type AnswerMetric = { label: string; value: string };
|
||||
|
||||
const answerText = computed(() => rewriteGregorianDates(props.answer || '').trim());
|
||||
|
||||
/** وقتی پاسخ شبیه «برچسب: مقدار» است، بهصورت کارت متریک نشان بده */
|
||||
const answerMetrics = computed((): AnswerMetric[] | null => {
|
||||
const text = answerText.value;
|
||||
if (!text) return null;
|
||||
|
||||
const linePairs = text
|
||||
.split(/\n+/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const m = line.match(/^([^::]+)\s*[::]\s*(.+)$/);
|
||||
return m ? { label: m[1].trim(), value: m[2].trim() } : null;
|
||||
});
|
||||
|
||||
if (linePairs.length >= 1 && linePairs.every(Boolean)) {
|
||||
return linePairs as AnswerMetric[];
|
||||
}
|
||||
|
||||
const inline = [...text.matchAll(/([^::\n]+?)\s*[::]\s*([۰-۹0-9٪%.,+\-]+)/g)].map((m) => ({
|
||||
label: m[1].trim(),
|
||||
value: m[2].trim(),
|
||||
}));
|
||||
return inline.length >= 2 ? inline : null;
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
|
||||
{ label: 'عقبافتادهها', prompt: 'فعالیتهای عقبافتاده چیست؟' },
|
||||
@@ -296,33 +331,40 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="home-ai-assistant" class="bg-white rounded-2xl border border-slate-200/80 p-3 shadow-sm flex flex-col justify-between h-[330px] gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
|
||||
<RobotOutlined />
|
||||
</span>
|
||||
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3>
|
||||
<section id="home-ai-assistant" class="home-ai">
|
||||
<div class="home-ai__pattern" aria-hidden="true" />
|
||||
<div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
|
||||
<div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
|
||||
|
||||
<header class="home-ai__head">
|
||||
<div class="home-ai__identity">
|
||||
<div class="home-ai__avatar">
|
||||
<MentoAiAvatar :size="42" :show-bubble="false" />
|
||||
</div>
|
||||
<div class="home-ai__titles min-w-0">
|
||||
<h3>{{ brand.aiNameFa }}</h3>
|
||||
<p>همراه هوشمند کسبوکار شما</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bg-emerald-50/50 border border-emerald-200/60 rounded-xl p-3 text-xs text-slate-600 leading-relaxed">
|
||||
<div class="home-ai__tip">
|
||||
<template v-if="answer">{{ answer }}</template>
|
||||
<template v-else-if="error">{{ error }}</template>
|
||||
<template v-else>
|
||||
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong>
|
||||
<strong>پیشنهاد هوشمند امروز:</strong>
|
||||
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200/90 rounded-xl p-1.5 flex items-center gap-2">
|
||||
<div class="home-ai__composer">
|
||||
<a-input
|
||||
:value="askQuery"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:bordered="false"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
class="flex-1"
|
||||
class="home-ai__input"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="onTypedSubmit"
|
||||
/>
|
||||
@@ -345,14 +387,14 @@ watch(
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div class="home-ai__chips">
|
||||
<span class="home-ai__chips-label">پیشنهادهای سریع</span>
|
||||
<div class="home-ai__chip-list">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="!text-[11px] !leading-4 !font-medium bg-emerald-50/70 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md disabled:opacity-50"
|
||||
class="home-ai__chip"
|
||||
:disabled="asking"
|
||||
@click="pick(item.prompt)"
|
||||
>
|
||||
@@ -361,10 +403,12 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
|
||||
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
|
||||
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
|
||||
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
|
||||
<div v-if="voiceBusy || confirming || voiceDone || voiceError" class="home-ai__status">
|
||||
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
|
||||
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
|
||||
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
|
||||
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
@@ -493,22 +537,365 @@ watch(
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="answerOpen" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer">
|
||||
<a-typography-paragraph style="white-space: pre-wrap">
|
||||
{{ rewriteGregorianDates(answer || '') }}
|
||||
</a-typography-paragraph>
|
||||
<a-space v-if="sources?.length" wrap>
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
<a-modal
|
||||
v-model:open="answerOpen"
|
||||
:width="560"
|
||||
centered
|
||||
destroy-on-close
|
||||
:body-style="{ paddingTop: `${token.paddingSM}px` }"
|
||||
@cancel="closeAnswer"
|
||||
>
|
||||
<template #title>
|
||||
<a-flex align="center" :gap="token.marginSM">
|
||||
<div
|
||||
:style="{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '999px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: `linear-gradient(145deg, ${token.colorSuccessBg}, ${token.colorBgContainer})`,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<MentoAiAvatar :size="36" :show-bubble="false" />
|
||||
</div>
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong :style="{ fontSize: `${token.fontSizeLG}px`, lineHeight: 1.3 }">
|
||||
پاسخ دستیار
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ brand.aiNameFa }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<a-row v-if="answerMetrics?.length" :gutter="[token.marginSM, token.marginSM]">
|
||||
<a-col
|
||||
v-for="(metric, idx) in answerMetrics"
|
||||
:key="`${metric.label}-${idx}`"
|
||||
:xs="24"
|
||||
:sm="answerMetrics.length === 1 ? 24 : 12"
|
||||
>
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
|
||||
}"
|
||||
>
|
||||
<a-flex vertical :gap="token.paddingXXS">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ metric.label }}
|
||||
</a-typography-text>
|
||||
<a-typography-title
|
||||
:level="3"
|
||||
:style="{
|
||||
margin: 0,
|
||||
color: token.colorPrimary,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}"
|
||||
>
|
||||
{{ metric.value }}
|
||||
</a-typography-title>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card
|
||||
v-else
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
|
||||
}"
|
||||
>
|
||||
<a-tag color="processing">{{ source.label }}</a-tag>
|
||||
</RouterLink>
|
||||
</a-space>
|
||||
<a-typography-paragraph
|
||||
:style="{
|
||||
marginBottom: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.85,
|
||||
color: token.colorText,
|
||||
}"
|
||||
>
|
||||
{{ answerText }}
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
|
||||
<template v-if="sources?.length">
|
||||
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
|
||||
<a-flex vertical :gap="token.paddingXS">
|
||||
<a-flex align="center" :gap="token.paddingXXS">
|
||||
<LinkOutlined />
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
منابع مرتبط
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="[8, 8]">
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
>
|
||||
<a-tag
|
||||
color="processing"
|
||||
:bordered="false"
|
||||
:style="{ cursor: 'pointer', borderRadius: `${token.borderRadiusSM}px` }"
|
||||
>
|
||||
{{ source.label }}
|
||||
</a-tag>
|
||||
</RouterLink>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-flex>
|
||||
|
||||
<template #footer>
|
||||
<a-button type="primary" @click="closeAnswer">بستن</a-button>
|
||||
<a-flex justify="end" :gap="token.paddingXS">
|
||||
<a-button type="primary" @click="closeAnswer">متوجه شدم</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-ai {
|
||||
--home-ai-h: 330px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: var(--home-ai-h);
|
||||
min-height: var(--home-ai-h);
|
||||
max-height: var(--home-ai-h);
|
||||
padding: 0.85rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgb(226 232 240 / 0.9);
|
||||
background:
|
||||
linear-gradient(165deg, #ffffff 0%, #f8fffc 48%, #ffffff 100%);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
.home-ai__pattern,
|
||||
.home-ai__orb {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.home-ai__pattern {
|
||||
inset: 0;
|
||||
opacity: 0.55;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.18) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
125deg,
|
||||
rgba(45, 212, 191, 0.06) 0%,
|
||||
transparent 42%,
|
||||
rgba(16, 185, 129, 0.05) 100%
|
||||
);
|
||||
background-size:
|
||||
16px 16px,
|
||||
auto;
|
||||
mask-image: linear-gradient(140deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.home-ai__orb {
|
||||
border-radius: 999px;
|
||||
filter: blur(28px);
|
||||
}
|
||||
|
||||
.home-ai__orb--a {
|
||||
top: -2.5rem;
|
||||
inset-inline-end: -1.5rem;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
background: rgb(52 211 153 / 0.22);
|
||||
}
|
||||
|
||||
.home-ai__orb--b {
|
||||
bottom: -2rem;
|
||||
inset-inline-start: -1.25rem;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
background: rgb(45 212 191 / 0.16);
|
||||
}
|
||||
|
||||
.home-ai__head,
|
||||
.home-ai__identity,
|
||||
.home-ai__composer,
|
||||
.home-ai__chips,
|
||||
.home-ai__chip-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-ai__head {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-ai__identity {
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-ai__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
background:
|
||||
radial-gradient(circle at 35% 25%, rgba(255, 255, 255, 0.9), transparent 55%),
|
||||
linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border: 1px solid rgb(167 243 208 / 0.8);
|
||||
box-shadow: 0 4px 12px rgb(16 185 129 / 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-ai__avatar :deep(.mento-bot) {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.home-ai__avatar :deep(.bot-img) {
|
||||
filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
|
||||
}
|
||||
|
||||
.home-ai__titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-ai__titles h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-ai__titles p {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-ai__tip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgb(167 243 208 / 0.55);
|
||||
background: rgb(236 253 245 / 0.55);
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.home-ai__tip strong {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-ai__composer {
|
||||
flex-shrink: 0;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.35rem 0.3rem 0.55rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgb(248 250 252 / 0.92);
|
||||
}
|
||||
|
||||
.home-ai__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.home-ai__chips {
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.65rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.home-ai__chips-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.home-ai__chip-list {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.home-ai__chip {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(167 243 208 / 0.7);
|
||||
background: rgb(236 253 245 / 0.75);
|
||||
color: #047857;
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.home-ai__chip:hover:not(:disabled) {
|
||||
background: #d1fae5;
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
|
||||
.home-ai__chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.home-ai__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: 0.7rem;
|
||||
max-height: 4.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.home-ai__status :deep(.ant-alert) {
|
||||
padding-block: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
LeftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardAlertDto } from '@mento/shared';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { GiftOutlined } from '@ant-design/icons-vue';
|
||||
import { GiftOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SearchOutlined, SendOutlined } from '@ant-design/icons-vue';
|
||||
import { SendOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Input as AInput,
|
||||
@@ -48,89 +48,62 @@ const statusMessage = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="home-hero relative overflow-hidden rounded-2xl border border-emerald-300/50 shadow-sm">
|
||||
<!-- base gradient -->
|
||||
<div class="home-hero__gradient pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- geometric pattern -->
|
||||
<div class="home-hero__pattern pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- soft orbs -->
|
||||
<div
|
||||
class="pointer-events-none absolute -left-20 -top-24 h-52 w-52 rounded-full bg-emerald-400/20 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-16 -bottom-20 h-48 w-48 rounded-full bg-teal-500/15 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="home-hero">
|
||||
<div class="home-hero__gradient" aria-hidden="true" />
|
||||
<div class="home-hero__pattern" aria-hidden="true" />
|
||||
<div class="home-hero__orb home-hero__orb--start" aria-hidden="true" />
|
||||
<div class="home-hero__orb home-hero__orb--end" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
class="relative flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div class="home-hero__identity relative shrink-0" :aria-label="firstName">
|
||||
<div class="home-hero__frame">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="home-hero__row">
|
||||
<div class="home-hero__greet">
|
||||
<div class="home-hero__identity" :aria-label="firstName">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] font-semibold tracking-wide text-emerald-700/80">
|
||||
{{ brand.nameFa }}
|
||||
</p>
|
||||
<a-typography-title
|
||||
:level="4"
|
||||
class="!m-0 !mt-0.5 !text-slate-800 !text-lg sm:!text-xl !font-extrabold !leading-7 truncate"
|
||||
>
|
||||
<div class="home-hero__copy">
|
||||
<p class="home-hero__brand">{{ brand.nameFa }}</p>
|
||||
<a-typography-title :level="4" class="home-hero__title">
|
||||
{{ greeting }}، {{ firstName }}
|
||||
</a-typography-title>
|
||||
<a-typography-text class="!text-slate-500 !text-sm !mt-0.5 block">
|
||||
<a-typography-text class="home-hero__subtitle">
|
||||
از دادهها بپرسید یا کار امروز را از همینجا شروع کنید
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 w-full lg:max-w-xl bg-white border border-white/80 rounded-2xl px-3 py-2 shadow-md shadow-emerald-900/5 focus-within:border-emerald-300 focus-within:ring-2 focus-within:ring-emerald-200/70 transition"
|
||||
>
|
||||
<span
|
||||
class="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<div class="home-hero__ask">
|
||||
<a-input
|
||||
id="home-hero-ask"
|
||||
:value="askQuery"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:disabled="asking"
|
||||
:bordered="false"
|
||||
class="flex-1 !bg-transparent"
|
||||
class="home-hero__ask-input"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="emit('ask')"
|
||||
/>
|
||||
<kbd
|
||||
class="hidden sm:inline-flex items-center text-[11px] font-sans text-slate-400 bg-slate-50 px-2 py-1 rounded-lg border border-slate-200 shrink-0"
|
||||
>
|
||||
{{ shortcutLabel }}
|
||||
</kbd>
|
||||
<kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="round"
|
||||
class="home-hero__ask-btn"
|
||||
:disabled="asking"
|
||||
:loading="asking"
|
||||
aria-label="ارسال"
|
||||
@click="emit('ask')"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
<span class="hidden sm:inline">بپرس</span>
|
||||
<span class="home-hero__ask-btn-inner">
|
||||
<SendOutlined />
|
||||
<span class="home-hero__ask-label">بپرس</span>
|
||||
</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,14 +132,39 @@ const statusMessage = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-hero {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgb(16 137 125 / 0.28);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
.home-hero__gradient,
|
||||
.home-hero__pattern,
|
||||
.home-hero__orb {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.home-hero__gradient,
|
||||
.home-hero__pattern {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.home-hero__gradient {
|
||||
background:
|
||||
linear-gradient(
|
||||
115deg,
|
||||
#ecfdf5 0%,
|
||||
#d1fae5 28%,
|
||||
#ccfbf1 55%,
|
||||
#f0fdfa 78%,
|
||||
#eef8f7 0%,
|
||||
#d0ebe8 28%,
|
||||
#c5e6e2 55%,
|
||||
#eef8f7 78%,
|
||||
#ffffff 100%
|
||||
);
|
||||
}
|
||||
@@ -174,37 +172,71 @@ const statusMessage = computed(() => {
|
||||
.home-hero__pattern {
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.28) 1px, transparent 0),
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 137, 125, 0.28) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 184, 166, 0.08) 25%,
|
||||
rgba(16, 137, 125, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(20, 184, 166, 0.08) 50%,
|
||||
rgba(20, 184, 166, 0.08) 75%,
|
||||
rgba(16, 137, 125, 0.08) 50%,
|
||||
rgba(16, 137, 125, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size:
|
||||
18px 18px,
|
||||
28px 28px;
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.home-hero__orb {
|
||||
border-radius: 999px;
|
||||
filter: blur(48px);
|
||||
}
|
||||
|
||||
.home-hero__orb--start {
|
||||
inset-inline-end: -5rem;
|
||||
top: -6rem;
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
background: rgb(22 193 168 / 0.22);
|
||||
}
|
||||
|
||||
.home-hero__orb--end {
|
||||
inset-inline-start: -4rem;
|
||||
bottom: -5rem;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
background: rgb(16 137 125 / 0.16);
|
||||
}
|
||||
|
||||
.home-hero__row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.home-hero__greet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
min-width: 0;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.home-hero__identity {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.home-hero__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(145deg, #34d399 0%, #0d9488 48%, #059669 100%);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 8px 18px -10px rgba(4, 120, 87, 0.55);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.home-hero__photo,
|
||||
@@ -212,32 +244,150 @@ const statusMessage = computed(() => {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(1rem - 2px);
|
||||
border-radius: 0.85rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.home-hero__monogram {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #ecfdf5;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
|
||||
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
|
||||
color: #eef8f7;
|
||||
background: linear-gradient(160deg, #16c1a8 0%, #10897d 100%);
|
||||
letter-spacing: -0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.home-hero__online {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
inset-inline-start: 0;
|
||||
bottom: 0;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #10b981;
|
||||
background: #10897d;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.home-hero__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-hero__brand {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(4 120 87 / 0.8);
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
margin: 0.125rem 0 0 !important;
|
||||
color: #1e293b !important;
|
||||
font-size: 1.125rem !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.75rem !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-hero__subtitle {
|
||||
display: block !important;
|
||||
margin-top: 0.125rem !important;
|
||||
color: #64748b !important;
|
||||
font-size: 0.875rem !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-hero__ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding-block: 0.375rem;
|
||||
padding-inline: 0.75rem 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(255 255 255 / 0.8);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 4px 12px rgb(6 78 59 / 0.05),
|
||||
0 1px 2px rgb(15 23 42 / 0.04);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.home-hero__ask:focus-within {
|
||||
border-color: #16c1a8;
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(16 137 125 / 0.18),
|
||||
0 4px 12px rgb(10 92 84 / 0.06);
|
||||
}
|
||||
|
||||
.home-hero__ask-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.home-hero__ask-kbd {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.home-hero__ask-btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.home-hero__ask-btn-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.home-hero__ask-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.home-hero__ask-kbd {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.home-hero__ask-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.home-hero__row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-hero__greet {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RiseOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import type { Component } from 'vue';
|
||||
import type { HomeDashboardKpiDto } from '@mento/shared';
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
FundOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { PipelineBoardDto } from '@mento/shared';
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CheckOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
Tag as ATag,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { TrophyOutlined } from '@ant-design/icons-vue';
|
||||
import { TrophyOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Flex as AFlex,
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
Segmented as ASegmented,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { UserOutlined } from '@/icons/ant-bridge';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import crownImg from '@/assets/club/crown.png';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
|
||||
{ value: 'today', label: 'امروز' },
|
||||
{ value: 'month', label: 'این ماه' },
|
||||
@@ -40,316 +58,342 @@ onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
|
||||
|
||||
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
|
||||
/** ترتیب نمایش از راست: سوم، اول، دوم */
|
||||
const podium = computed(() => {
|
||||
const [a, b, c] = top3.value;
|
||||
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x);
|
||||
});
|
||||
const rest = computed(() => {
|
||||
const items = board.value?.items ?? [];
|
||||
return top3.value.length ? items.slice(top3.value.length) : items;
|
||||
});
|
||||
function chgClass(change: number | null) {
|
||||
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : '';
|
||||
const allItems = computed(() => board.value?.items ?? []);
|
||||
|
||||
interface PodiumSlot {
|
||||
rank: number;
|
||||
entry: GamificationLeaderEntryDto | null;
|
||||
stepHeight: number;
|
||||
avatarSize: number;
|
||||
label: string;
|
||||
gradient: string;
|
||||
numberColor: string;
|
||||
ringColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
/** چیدمان ۳ پله سکوی قهرمانی به ترتیب ورزشی از راست به چپ: دوم (نقره)، اول (طلا - مرکز)، سوم (برنز) */
|
||||
const podiumSlots = computed<PodiumSlot[]>(() => {
|
||||
const items = allItems.value;
|
||||
if (!items.length) return [];
|
||||
|
||||
const first = items[0] ?? null;
|
||||
const second = items[1] ?? null;
|
||||
const third = items[2] ?? null;
|
||||
|
||||
return [
|
||||
{
|
||||
rank: 2,
|
||||
entry: second,
|
||||
stepHeight: 74,
|
||||
avatarSize: 48,
|
||||
label: 'نقره',
|
||||
gradient: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 45%, #94a3b8 100%)',
|
||||
numberColor: '#334155',
|
||||
ringColor: '#94a3b8',
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
{
|
||||
rank: 1,
|
||||
entry: first,
|
||||
stepHeight: 110,
|
||||
avatarSize: 58,
|
||||
label: 'طلا',
|
||||
gradient: 'linear-gradient(180deg, #fef08a 0%, #f59e0b 55%, #d97706 100%)',
|
||||
numberColor: '#78350f',
|
||||
ringColor: '#f59e0b',
|
||||
borderColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
entry: third,
|
||||
stepHeight: 52,
|
||||
avatarSize: 44,
|
||||
label: 'برنز',
|
||||
gradient: 'linear-gradient(180deg, #e8b896 0%, #b87333 50%, #7c4a1e 100%)',
|
||||
numberColor: '#5c3317',
|
||||
ringColor: '#b87333',
|
||||
borderColor: '#cd7f32',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const rest = computed(() => {
|
||||
const items = allItems.value;
|
||||
return items.length > 3 ? items.slice(3) : [];
|
||||
});
|
||||
|
||||
const meOutside = computed(() => {
|
||||
const b = board.value;
|
||||
if (!b?.me) return null;
|
||||
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
|
||||
const inTop3 = allItems.value.slice(0, 3).some((i) => i.userId === b.me!.userId);
|
||||
const inRest = rest.value.some((i) => i.userId === b.me!.userId);
|
||||
return inTop3 || inRest ? null : b.me;
|
||||
});
|
||||
|
||||
type Row = { key: string; sep: boolean; entry: GamificationLeaderEntryDto | null };
|
||||
const rows = computed<Row[]>(() => {
|
||||
const list: Row[] = rest.value.map((e) => ({ key: e.userId, sep: false, entry: e }));
|
||||
if (meOutside.value) {
|
||||
list.push({ key: '__sep', sep: true, entry: null });
|
||||
list.push({ key: `__me-${meOutside.value.userId}`, sep: false, entry: meOutside.value });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function changeType(change: number | null): 'success' | 'danger' | undefined {
|
||||
return change && change > 0 ? 'success' : change && change < 0 ? 'danger' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="board">
|
||||
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="period === p.value"
|
||||
:class="{ on: period === p.value }"
|
||||
@click="period = p.value"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<a-card title="جدول قهرمانان و رتبهبندی">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<div v-if="podium.length" class="top3" :class="{ loading }">
|
||||
<div
|
||||
v-for="e in podium"
|
||||
:key="e.userId"
|
||||
class="t-card"
|
||||
:class="[`r${e.rank}`, { me: e.isMe }]"
|
||||
>
|
||||
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" />
|
||||
<span class="t-ava">
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" />
|
||||
</span>
|
||||
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
|
||||
<small>{{ faNum(e.score) }} امتیاز</small>
|
||||
</div>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-flex vertical :gap="16">
|
||||
<!-- بخش سکوی قهرمانی ۳ پله با ارتفاعهای نامساوی (Podium) -->
|
||||
<div
|
||||
v-if="podiumSlots.length"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '24px 12px 0 12px',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<!-- ردیف ۳ پله قهرمانی همتراز در کف سکو -->
|
||||
<a-flex justify="center" align="flex-end" :gap="12" :style="{ minHeight: '220px', width: '100%' }">
|
||||
<div
|
||||
v-for="slot in podiumSlots"
|
||||
:key="slot.rank"
|
||||
:style="{
|
||||
flex: 1,
|
||||
maxWidth: '140px',
|
||||
minWidth: '85px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}"
|
||||
>
|
||||
<!-- اطلاعات کاربر بالای پله (تصویر، نام، امتیاز) -->
|
||||
<a-flex
|
||||
vertical
|
||||
align="center"
|
||||
:gap="4"
|
||||
:style="{
|
||||
width: '100%',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
minWidth: 0,
|
||||
}"
|
||||
>
|
||||
<!-- تاج درخشان نفر اول -->
|
||||
<div
|
||||
v-if="slot.rank === 1"
|
||||
:style="{
|
||||
height: '26px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="crownImg"
|
||||
alt="نفر اول"
|
||||
:style="{ width: '28px', height: '28px', objectFit: 'contain' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else :style="{ height: '26px' }" />
|
||||
|
||||
<ol class="rows" :class="{ loading }">
|
||||
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
|
||||
<span class="rank">{{ toFaDigits(e.rank) }}</span>
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" />
|
||||
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b>
|
||||
<span class="score">{{ faNum(e.score) }}</span>
|
||||
<span class="chg" :class="chgClass(e.change)">
|
||||
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template>
|
||||
</span>
|
||||
</li>
|
||||
<template v-if="meOutside">
|
||||
<li class="sep" aria-hidden="true">⋯</li>
|
||||
<li class="me">
|
||||
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
|
||||
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" />
|
||||
<b class="who">شما</b>
|
||||
<span class="score">{{ faNum(meOutside.score) }}</span>
|
||||
<span class="chg" :class="chgClass(meOutside.change)">
|
||||
<template v-if="meOutside.change">
|
||||
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }}
|
||||
<!-- عکس / آواتار کاربر با حلقه متالیک متناسب با رتبه -->
|
||||
<template v-if="slot.entry">
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
padding: '2px',
|
||||
borderRadius: '999px',
|
||||
background: '#ffffff',
|
||||
boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
:user-id="slot.entry.userId"
|
||||
:has-avatar="slot.entry.hasAvatar"
|
||||
:name="slot.entry.fullName"
|
||||
:size="slot.avatarSize"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<a-flex align="center" :gap="4" :style="{ maxWidth: '100%' }">
|
||||
<a-typography-text
|
||||
strong
|
||||
:ellipsis="true"
|
||||
:style="{
|
||||
fontSize: slot.rank === 1 ? '13px' : '12px',
|
||||
color: slot.entry.isMe ? token.colorPrimary : token.colorTextHeading,
|
||||
maxWidth: '100%',
|
||||
}"
|
||||
>
|
||||
{{ slot.entry.isMe ? 'شما' : slot.entry.fullName }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-tag
|
||||
v-if="slot.entry.isMe"
|
||||
color="success"
|
||||
:bordered="false"
|
||||
:style="{ margin: 0, fontSize: '10px', lineHeight: '18px', padding: '0 6px' }"
|
||||
>
|
||||
شما
|
||||
</a-tag>
|
||||
|
||||
<a-typography-text
|
||||
:style="{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: slot.rank === 1 ? '#d97706' : token.colorTextSecondary,
|
||||
}"
|
||||
>
|
||||
{{ faNum(slot.entry.score) }} امتیاز
|
||||
</a-typography-text>
|
||||
</template>
|
||||
|
||||
<!-- حالت خالی اگر در تیم جایگاه پر نشده باشد -->
|
||||
<template v-else>
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: `${slot.avatarSize}px`,
|
||||
height: `${slot.avatarSize}px`,
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(0, 0, 0, 0.04)',
|
||||
border: '2px dashed #cbd5e1',
|
||||
color: '#94a3b8',
|
||||
}"
|
||||
>
|
||||
<UserOutlined :style="{ fontSize: '20px' }" />
|
||||
</span>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
خالی
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
|
||||
—
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-flex>
|
||||
|
||||
<!-- پله سکوی قهرمانی با ارتفاع اختصاصی و جلوه ۳ بعدی -->
|
||||
<div
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: `${slot.stepHeight}px`,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
background: slot.gradient,
|
||||
border: `1px solid ${slot.borderColor}`,
|
||||
borderBottom: 'none',
|
||||
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.06), inset 0 1px 2px rgba(255, 255, 255, 0.7)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
fontSize: slot.rank === 1 ? '28px' : '22px',
|
||||
fontWeight: 900,
|
||||
color: slot.numberColor,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 1px rgba(255, 255, 255, 0.8)',
|
||||
}"
|
||||
>
|
||||
{{ toFaDigits(slot.rank) }}
|
||||
</span>
|
||||
<span
|
||||
:style="{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: slot.numberColor,
|
||||
opacity: 0.85,
|
||||
marginTop: '2px',
|
||||
}"
|
||||
>
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<!-- خط پایه و زمین زیر پلههای سکو -->
|
||||
<div
|
||||
:style="{
|
||||
width: 'calc(100% + 24px)',
|
||||
marginInline: '-12px',
|
||||
height: '10px',
|
||||
background: '#e2e8f0',
|
||||
borderTop: '2px solid #cbd5e1',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- لیست نفرات چهارم به بعد در جدول -->
|
||||
<a-list v-if="rows.length" :data-source="rows" :split="true">
|
||||
<template #renderItem="{ item }">
|
||||
<a-divider v-if="item.sep" :style="{ margin: 0 }">⋯</a-divider>
|
||||
<a-list-item
|
||||
v-else-if="item.entry"
|
||||
:style="item.entry.isMe ? { background: token.colorWarningBg, borderRadius: `${token.borderRadius}px` } : undefined"
|
||||
>
|
||||
<a-flex align="center" :gap="12" :style="{ width: '100%' }">
|
||||
<a-typography-text type="secondary" :style="{ width: '28px', textAlign: 'center', fontWeight: 'bold' }">
|
||||
{{ toFaDigits(item.entry.rank) }}
|
||||
</a-typography-text>
|
||||
<UserAvatar
|
||||
:user-id="item.entry.userId"
|
||||
:has-avatar="item.entry.hasAvatar"
|
||||
:name="item.entry.fullName"
|
||||
:size="32"
|
||||
/>
|
||||
<a-typography-text
|
||||
:strong="item.entry.isMe"
|
||||
:ellipsis="true"
|
||||
:style="{ flex: 1, minWidth: 0 }"
|
||||
>
|
||||
{{ item.entry.isMe ? 'شما' : item.entry.fullName }}
|
||||
</a-typography-text>
|
||||
<a-typography-text :strong="item.entry.isMe">
|
||||
{{ faNum(item.entry.score) }} امتیاز
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
:type="changeType(item.entry.change)"
|
||||
strong
|
||||
:style="{ minWidth: '32px', textAlign: 'end' }"
|
||||
>
|
||||
<template v-if="item.entry.change">
|
||||
{{ item.entry.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(item.entry.change)) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</a-list>
|
||||
|
||||
<a-empty
|
||||
v-if="board && !board.items.length"
|
||||
description="هنوز کسی در این بازه امتیاز نگرفته است."
|
||||
/>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 0.75rem 0.9rem 0.5rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.periods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.periods button {
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.periods button.on {
|
||||
background: #e0e7ff;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.top3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
.t-card {
|
||||
--bg: #e0e7ff;
|
||||
--ring: #a5b4fc;
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0.4rem 0.7rem;
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.t-card.r1 {
|
||||
--bg: #fef3c7;
|
||||
--ring: #f59e0b;
|
||||
padding-bottom: 1rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.t-card.r3 {
|
||||
--bg: #ffedd5;
|
||||
--ring: #fb923c;
|
||||
}
|
||||
|
||||
.t-card.me {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.t-crown {
|
||||
position: absolute;
|
||||
top: -1.55rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.t-ava {
|
||||
margin-top: -1.4rem;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px var(--ring);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.t-card.r1 .t-ava {
|
||||
margin-top: -0.4rem;
|
||||
}
|
||||
|
||||
.t-card strong {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.t-card small {
|
||||
font-size: 0.76rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.t-card.r1 small {
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.rows li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rows li.me {
|
||||
background: #fef9e7;
|
||||
border-radius: 10px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.rows li.me .score,
|
||||
.rows li.me .who {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rows li.sep {
|
||||
justify-content: center;
|
||||
color: var(--muted-light);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chg {
|
||||
min-width: 1.8rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chg.up {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.chg.down {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.score {
|
||||
min-width: 2.8rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 1.6rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.top3 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.t-card strong {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
AimOutlined,
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
ContainerOutlined,
|
||||
CrownOutlined,
|
||||
DollarOutlined,
|
||||
FileTextOutlined,
|
||||
FilterOutlined,
|
||||
FireOutlined,
|
||||
GiftOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
RiseOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ShoppingOutlined,
|
||||
SlidersOutlined,
|
||||
StarOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
/** نگاشت نام آیکونهای باشگاه (txIcon / پاداش / نشان) به آیکونهای Ant Design */
|
||||
const CLUB_ICONS: Record<string, Component> = {
|
||||
trophy: TrophyOutlined,
|
||||
'user-plus': UserAddOutlined,
|
||||
funnel: FilterOutlined,
|
||||
briefcase: ContainerOutlined,
|
||||
phone: PhoneOutlined,
|
||||
calendar: CalendarOutlined,
|
||||
'calendar-sun': CalendarOutlined,
|
||||
document: FileTextOutlined,
|
||||
check: CheckCircleOutlined,
|
||||
refresh: SyncOutlined,
|
||||
dollar: DollarOutlined,
|
||||
chat: MessageOutlined,
|
||||
star: StarOutlined,
|
||||
medal: SafetyCertificateOutlined,
|
||||
gift: GiftOutlined,
|
||||
sliders: SlidersOutlined,
|
||||
building: BankOutlined,
|
||||
trend: RiseOutlined,
|
||||
crown: CrownOutlined,
|
||||
bag: ShoppingOutlined,
|
||||
target: AimOutlined,
|
||||
fire: FireOutlined,
|
||||
users: TeamOutlined,
|
||||
};
|
||||
|
||||
export function clubIcon(name: string | null | undefined): Component {
|
||||
return (name && CLUB_ICONS[name]) || StarOutlined;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
border-radius: 28px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ onUnmounted(() => {
|
||||
gap: 0.28rem;
|
||||
padding: 0.65rem 0.7rem 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f7f8fa;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
@@ -267,7 +267,7 @@ onUnmounted(() => {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
@@ -316,7 +316,7 @@ onUnmounted(() => {
|
||||
max-height: min(86dvh, calc(100dvh - 3.5rem));
|
||||
overflow: auto;
|
||||
padding: 0.85rem 0.9rem 0.9rem;
|
||||
border-radius: 24px 24px 0 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
CalendarOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
} from 'ant-design-vue';
|
||||
import { MoreOutlined } from '@ant-design/icons-vue';
|
||||
import { MoreOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
export type ProfileMoreItem = {
|
||||
key: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined } from '@/icons/ant-bridge';
|
||||
import { Button as AButton } from 'ant-design-vue';
|
||||
import type { PipelineAccent } from '@/lib/pipeline-board';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
FlagFilled,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
@@ -13,10 +14,12 @@ import {
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||
import type { ActivityPriority } from '@mento/shared';
|
||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
||||
|
||||
@@ -60,14 +63,26 @@ const extraAssignees = computed(() =>
|
||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||
);
|
||||
const showTrash = computed(() => props.selectMode && props.selected);
|
||||
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
||||
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
||||
high: { label: 'زیاد', color: '#ea580c' },
|
||||
medium: { label: 'متوسط', color: '#eab308' },
|
||||
low: { label: 'کم', color: '#16a34a' },
|
||||
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
||||
};
|
||||
const priorityFlag = computed(() =>
|
||||
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
@@ -194,6 +209,11 @@ const cardStyle = computed(() => ({
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
|
||||
import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Dropdown as ADropdown,
|
||||
@@ -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 } 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, index);
|
||||
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"
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
|
||||
@@ -9,8 +9,31 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiField, UiSelect } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Steps as ASteps,
|
||||
Step as AStep,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AimOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
type Step = 'target' | 'map' | 'review';
|
||||
|
||||
@@ -31,10 +54,23 @@ const stageMap = ref<Record<string, string>>({});
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const stepIndex = computed(() => {
|
||||
if (step.value === 'target') return 0;
|
||||
if (step.value === 'map') return 1;
|
||||
return 2;
|
||||
});
|
||||
|
||||
const selectedTarget = computed(
|
||||
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null,
|
||||
);
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
(preview.value?.targets ?? []).map((target) => ({
|
||||
value: target.id,
|
||||
label: target.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
|
||||
const canContinueMap = computed(() => {
|
||||
const current = preview.value;
|
||||
@@ -71,6 +107,13 @@ function compatibleTargets(src: PipelineMergePreviewStageDto) {
|
||||
});
|
||||
}
|
||||
|
||||
function compatibleOptions(src: PipelineMergePreviewStageDto) {
|
||||
return compatibleTargets(src).map((stage) => ({
|
||||
value: stage.id,
|
||||
label: stage.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
|
||||
if (stage.isWon) return 'برد';
|
||||
if (stage.isLost) return 'باخت';
|
||||
@@ -171,298 +214,181 @@ watch(targetId, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="merge-wizard">
|
||||
<ol class="steps" aria-label="مراحل ادغام کاریز">
|
||||
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li>
|
||||
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li>
|
||||
<li :class="{ active: step === 'review' }">۳. بازبینی</li>
|
||||
</ol>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-steps :current="stepIndex">
|
||||
<a-step title="مقصد" />
|
||||
<a-step title="نگاشت" />
|
||||
<a-step title="بازبینی" />
|
||||
</a-steps>
|
||||
|
||||
<p v-if="busy && !preview" class="muted">در حال آمادهسازی پیشنمایش…</p>
|
||||
<a-spin :spinning="busy && !preview">
|
||||
<template v-if="preview">
|
||||
<a-flex v-if="step === 'target'" vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<AppstoreOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
کاریز مقصد را انتخاب کنید
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
معاملات و فرمهای سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل میشوند.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<template v-else-if="preview">
|
||||
<section v-if="step === 'target'" class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="layers" :size="20" />
|
||||
<div>
|
||||
<h3>کاریز مقصد را انتخاب کنید</h3>
|
||||
<p>
|
||||
معاملات و فرمهای سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل میشوند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="کاریز مقصد" required>
|
||||
<a-select
|
||||
v-model:value="targetId"
|
||||
:options="targetOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<UiField label="کاریز مقصد" required>
|
||||
<UiSelect v-model="targetId" required>
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="target in preview.targets" :key="target.id" :value="target.id">
|
||||
{{ target.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="true">
|
||||
<a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">معامله منتقل میشود</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="true">
|
||||
<a-typography-text strong>{{ toFaDigits(preview.leadForms.length) }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">فرم سرنخ به مقصد وصل میشود</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col v-if="preview.source.isDefault" :xs="24" :md="8">
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="هشدار"
|
||||
description="کاریز مبدا پیشفرض است و بعد از ادغام حذف میشود."
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article>
|
||||
<strong>{{ toFaDigits(preview.dealCount) }}</strong>
|
||||
<span>معامله منتقل میشود</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong>
|
||||
<span>فرم سرنخ به مقصد وصل میشود</span>
|
||||
</article>
|
||||
<article v-if="preview.source.isDefault" class="warn">
|
||||
<strong>هشدار</strong>
|
||||
<span>کاریز مبدا پیشفرض است و بعد از ادغام حذف میشود.</span>
|
||||
</article>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="selectedTarget?.isDefault"
|
||||
type="info"
|
||||
show-icon
|
||||
message="مقصد انتخابشده کاریز پیشفرض است."
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخابشده کاریز پیشفرض است.</p>
|
||||
</section>
|
||||
<a-flex v-else-if="step === 'map'" vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<AimOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
مرحلههای مبدا را نگاشت کنید
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
هر مرحله فقط به مرحلهای با وضعیت سازگار در مقصد وصل میشود.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<section v-else-if="step === 'map'" class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="target" :size="20" />
|
||||
<div>
|
||||
<h3>مرحلههای مبدا را نگاشت کنید</h3>
|
||||
<p>هر مرحله فقط به مرحلهای با وضعیت سازگار در مقصد وصل میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-row
|
||||
v-for="sourceStage in preview.stages"
|
||||
:key="sourceStage.id"
|
||||
:gutter="[16, 0]"
|
||||
align="middle"
|
||||
>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text strong>{{ sourceStage.name }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="مرحله مقصد"
|
||||
required
|
||||
:validate-status="stageMap[sourceStage.id] ? undefined : 'error'"
|
||||
:style="{ marginBottom: '12px' }"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="stageMap[sourceStage.id]"
|
||||
:options="compatibleOptions(sourceStage)"
|
||||
placeholder="مرحله مقصد"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-flex>
|
||||
|
||||
<div class="map-list">
|
||||
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row">
|
||||
<div class="stage-info">
|
||||
<strong>{{ sourceStage.name }}</strong>
|
||||
<span>
|
||||
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
|
||||
</span>
|
||||
</div>
|
||||
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]">
|
||||
<option value="" disabled>مرحله مقصد</option>
|
||||
<option
|
||||
v-for="targetStage in compatibleTargets(sourceStage)"
|
||||
:key="targetStage.id"
|
||||
:value="targetStage.id"
|
||||
>
|
||||
{{ targetStage.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<a-flex v-else vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<CheckCircleOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
|
||||
<a-typography-paragraph type="secondary" :style="{ margin: 0 }">
|
||||
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل میشود.
|
||||
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل میشود.
|
||||
کاریز «{{ preview.source.name }}» حذف میشود.
|
||||
</a-typography-paragraph>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<section v-else class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="check" :size="20" />
|
||||
<div>
|
||||
<h3>بازبینی نهایی</h3>
|
||||
<p>
|
||||
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل میشود.
|
||||
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل میشود.
|
||||
کاریز «{{ preview.source.name }}» حذف میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="روش فرمها">
|
||||
همه فرمهای سرنخ به مقصد وصل میشوند.
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<dl class="review-list">
|
||||
<div>
|
||||
<dt>مبدا</dt>
|
||||
<dd>{{ preview.source.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>مقصد</dt>
|
||||
<dd>{{ selectedTarget?.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>روش فرمها</dt>
|
||||
<dd>همه فرمهای سرنخ به مقصد وصل میشوند.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</template>
|
||||
<a-typography-text v-else-if="!busy && !error" type="secondary">
|
||||
<template v-if="sourceId">در حال بارگذاری پیشنمایش ادغام…</template>
|
||||
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
|
||||
</a-typography-text>
|
||||
</a-spin>
|
||||
|
||||
<p v-else-if="!busy && !error" class="muted">
|
||||
<template v-if="sourceId">در حال بارگذاری پیشنمایش ادغام…</template>
|
||||
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
|
||||
</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
|
||||
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'">
|
||||
قبلی
|
||||
</UiButton>
|
||||
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'">
|
||||
قبلی
|
||||
</UiButton>
|
||||
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
|
||||
<a-space wrap>
|
||||
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
|
||||
<a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
|
||||
<a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
|
||||
<a-button
|
||||
v-if="step === 'target'"
|
||||
type="primary"
|
||||
:disabled="!preview || !canContinueTarget"
|
||||
@click="nextFromTarget"
|
||||
>
|
||||
ادامه
|
||||
</UiButton>
|
||||
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="step === 'map'"
|
||||
type="primary"
|
||||
:disabled="!canContinueMap"
|
||||
@click="nextFromMap"
|
||||
>
|
||||
بازبینی
|
||||
</UiButton>
|
||||
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
danger
|
||||
:loading="busy"
|
||||
:disabled="!canContinueMap"
|
||||
@click="submitMerge"
|
||||
>
|
||||
ادغام و حذف مبدا
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.merge-wizard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.steps li.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.steps li.done {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.intro h3,
|
||||
.intro p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid article {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.muted,
|
||||
.note {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-grid .warn {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.map-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.map-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 18rem);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.stage-info {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stage-info span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-list div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.review-list dt,
|
||||
.review-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-list dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.map-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
FundOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
InputNumber as AInputNumber,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { CloseOutlined } from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { CheckOutlined, PlusOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
|
||||
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
@@ -132,7 +132,8 @@ function onCreated(t: RoutineTaskDto) {
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading && !items.length">
|
||||
<CrmSkeleton :loading="loading && !items.length">
|
||||
<CrmSkeletonList :rows="4">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
:description="emptyTitle"
|
||||
@@ -185,7 +186,8 @@ function onCreated(t: RoutineTaskDto) {
|
||||
/>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
|
||||
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user