27 Commits
Author SHA1 Message Date
Mahdi 0998a1ec37 fix(web): serve real dashboard data instead of mocks
Drop forced home/treasury/reports mock fallbacks so API and database content drive the UI.
2026-11-07 09:04:38 +03:30
Mahdi 4979aa8b75 feat(web): refresh Montact brand and sidebar chrome
Ship the new logo assets, set primary to #10897d, and polish sidebar/home-hero UX around roles, org switcher, and the ask bar.
2026-11-05 17:10:03 +03:30
MahdiandCursor 34d3689448 chore(web): keep the raw vuesax icon dump for regenerating the pack
Track icons (1).json so scripts/_setup-vuesax.mjs can rebuild apps/web/src/icons/vuesax-linear.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:34:34 +03:30
MahdiandCursor 1e0b638a19 fix(web): tidy the notification panel with status icons
Keep cards neutral, show type through icon and color only, and hide the panel scrollbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:29:40 +03:30
Mahdi 3db9c1c0fe merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
2026-11-05 15:10:48 +03:30
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30
MahdiandCursor 1ef7f91771 fix(contacts): keep one customer when Tahesab repeats a mobile
A Tahesab import now updates the person who already has that mobile instead of creating another, and the boot merge runs once more so existing pairs collapse.

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 11:36:55 +03:30
MahdiandCursor 41f310f3a2 merge: bring latest origin/main into dev (design local, logic main)
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:26:43 +03:30
MahdiandCursor 754cc43c98 fix(web): drop unused viewingSelf after subtitle removal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor a319085dca fix(web): clear vue-tsc errors blocking push
Type attendance rows, remove unused symbols, and harden list item typing after the Ant settings migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30
MahdiandCursor 1ccbefa7a3 fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:02:31 +03:30
MahdiandCursor 14f4e9d7d8 fix(activities): put the priority flag on the date row and recolor medium and low
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:32:49 +03:30
MahdiandCursor 52edccf74e fix(boards): keep card order inside a column and show activity priority
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 21:22:30 +03:30
MahdiandCursor bffe174162 fix(activities): show form photos in the note instead of the raw file link
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:30:45 +03:30
MahdiandCursor 35d74d33ed fix(activities): keep lead activity actions in the detail card
The action row stretched off-screen and comments on automation activities were rejected once the saved note passed 2000 characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 17:04:59 +03:30
209 changed files with 20424 additions and 16385 deletions
+37
View File
@@ -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

+25
View File
@@ -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;
+32
View File
@@ -295,6 +295,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
assignedActivities Activity[] @relation("ActivityAssignees") assignedActivities Activity[] @relation("ActivityAssignees")
@@ -353,6 +354,10 @@ model OrgRole {
permissions String[] @default([]) permissions String[] @default([])
/// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}} /// عمق داده به‌ازای بخش، مثلاً {"customers":{"mode":"rules","rules":[{"owners":["self"]}]}}
dataAccessPolicy Json @default("{}") dataAccessPolicy Json @default("{}")
/// حساب‌های مالی اضافی این نقش؛ همراه با حساب‌هایی که خودش مسئول است
treasuryAccountIds String[] @default([])
/// اعضای نقش، حساب‌هایی را ببینند که نام خودشان مسئول آن است
treasuryOwnAccounts Boolean @default(false)
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String? systemKey String?
/// نقش والد برای سلسله‌مراتب زیرنقش‌ها /// نقش والد برای سلسله‌مراتب زیرنقش‌ها
@@ -387,6 +392,8 @@ model Membership {
dataVisibilityScopes Json @default("{}") dataVisibilityScopes Json @default("{}")
/// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy /// Override عمق داده نسبت به نقش؛ کلید غایب = ارث از OrgRole.dataAccessPolicy
dataAccessOverride Json @default("{}") dataAccessOverride Json @default("{}")
/// null = دسترسی مالی از نقش؛ وگرنه جایگزین بخش‌ها و حساب‌های مالی همین کاربر
treasuryAccessOverride Json?
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد /// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false) priorArchiveEnabled Boolean @default(false)
/// current | archive /// current | archive
@@ -518,6 +525,7 @@ model Company {
customFields Json @default("{}") customFields Json @default("{}")
groupLinks CompanyGroupLink[] groupLinks CompanyGroupLink[]
people Person[] people Person[]
financialDocuments FinancialDocument[]
deals Deal[] deals Deal[]
activities Activity[] activities Activity[]
smartNotes Note[] smartNotes Note[]
@@ -896,6 +904,8 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String? stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
/// ترتیب کارت داخل همان ستون کانبان
boardPosition Int @default(0)
/// نوع سفارشی؛ null → نوع اصلیِ همان type /// نوع سفارشی؛ null → نوع اصلیِ همان type
typeDefId String? typeDefId String?
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull) typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
@@ -911,6 +921,7 @@ model Activity {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([stageId]) @@index([stageId])
@@index([stageId, boardPosition])
@@index([boardId]) @@index([boardId])
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId]) @@index([leadId])
@@ -1832,6 +1843,13 @@ model AccountingConnection {
lastHealthAt DateTime? lastHealthAt DateTime?
lastHealthOk Boolean? lastHealthOk Boolean?
lastError String? lastError String?
/// ساعت‌های بروزرسانی خودکار مشتریان (۰ تا ۲۳، وقت تهران). خالی یعنی خاموش.
customerSyncHours Int[] @default([])
/// نوبت claimشده، مثل 2026-11-05-08، تا در همان ساعت دو بار اجرا نشود
customerSyncLastSlot String?
customerSyncLastRunAt DateTime?
customerSyncLastSummary String?
customerSyncLastError String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
personLinks PersonAccountingLink[] personLinks PersonAccountingLink[]
@@ -2321,6 +2339,12 @@ model FinancialDocument {
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) 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? partyName String?
accountId String? accountId String?
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
@@ -2341,6 +2365,8 @@ model FinancialDocument {
@@index([organizationId, kind, bookedAt]) @@index([organizationId, kind, bookedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([companyId])
@@index([userId])
} }
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
@@ -3760,3 +3786,9 @@ model AttendanceEvent {
@@index([organizationId, userId, at]) @@index([organizationId, userId, at])
@@unique([organizationId, userId, kind, sessionStartedAt]) @@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 { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope'; import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatClock, formatClock,
@@ -357,7 +357,7 @@ export class ActivitiesExcelService {
return id; return id;
}; };
const resolvePerson = async (row: Record<string, string>) => { const resolvePerson = async (row: Record<string, string>) => {
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const name = (row.personName ?? '').trim().toLowerCase(); const name = (row.personName ?? '').trim().toLowerCase();
const key = mobile ? `m:${mobile}` : name ? `n:${name}` : ''; const key = mobile ? `m:${mobile}` : name ? `n:${name}` : '';
if (!key) return null; if (!key) return null;
@@ -478,10 +478,13 @@ export class ActivitiesExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const byMobile = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -428,7 +428,13 @@ export class ActivitiesController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: MoveActivityStageDto, @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') @Patch(':id/complete')
+39 -4
View File
@@ -31,6 +31,7 @@ import {
ownerWhere, ownerWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access'; import { assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
@@ -329,9 +330,7 @@ export class ActivitiesService {
where, where,
include, include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId orderBy: this.listOrder(entity, stageScope),
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take, skip: (page - 1) * take,
take, take,
}), }),
@@ -385,7 +384,7 @@ export class ActivitiesService {
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where, where,
select: { id: true }, select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], orderBy: this.listOrder(entity, stageScope),
take: cap, take: cap,
}), }),
this.prisma.activity.count({ where }), this.prisma.activity.count({ where }),
@@ -490,11 +489,46 @@ export class ActivitiesService {
return { counts, total }; 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( async moveToStage(
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
stageId: string, stageId: string,
position?: number,
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
@@ -519,6 +553,7 @@ export class ActivitiesService {
}, },
include, include,
}); });
if (position != null) await this.reorderStage(organizationId, stage.id, id, position);
if (stage.kind === 'done' && existing?.status !== 'done') { if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
+8 -2
View File
@@ -219,6 +219,12 @@ export class ReorderActivityStagesDto {
export class MoveActivityStageDto { export class MoveActivityStageDto {
@IsString() @IsString()
stageId!: string; stageId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
} }
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */ /** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
@@ -316,7 +322,7 @@ export class CreateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@@ -416,7 +422,7 @@ export class UpdateActivityDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(20000)
description?: string | null; description?: string | null;
@IsOptional() @IsOptional()
+28
View File
@@ -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)));
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller'; import { PeopleController } from './people.controller';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { SameMobileMergeService } from './same-mobile-merge.service';
import { BehaviorModule } from '../behavior/behavior.module'; import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@@ -21,7 +22,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
CustomerGroupsModule, CustomerGroupsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
exports: [PeopleService, CompaniesService], exports: [PeopleService, CompaniesService],
}) })
export class ContactsModule {} export class ContactsModule {}
+367
View File
@@ -0,0 +1,367 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Prisma, PrismaClient } from '@prisma/client';
import { canonicalIranMobile, storedPhoneNumber } from './normalize';
const personSelect = {
id: true,
createdAt: true,
firstName: true,
lastName: true,
gender: true,
showGenderInName: true,
previousFirstName: true,
previousLastName: true,
email: true,
phone: true,
mobile: true,
position: true,
website: true,
instagram: true,
birthDate: true,
referrer: true,
addresses: true,
notes: true,
tags: true,
companyId: true,
customFields: true,
importSource: true,
sourceRef: true,
previousImportSource: true,
previousSourceRef: true,
loyaltyPoints: true,
discLabel: true,
discReliable: true,
discAssessedAt: true,
} satisfies Prisma.PersonSelect;
type PersonRow = Prisma.PersonGetPayload<{ select: typeof personSelect }>;
function blank(value: string | null | undefined) {
return !value?.trim();
}
function asStrings(value: Prisma.JsonValue): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item)).filter(Boolean);
}
function asObject(value: Prisma.JsonValue): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return { ...(value as Record<string, unknown>) };
}
function mergedFields(keeper: PersonRow, extras: PersonRow[], canon: string): Prisma.PersonUncheckedUpdateInput {
const notes = [keeper.notes?.trim() || ''];
let email = keeper.email;
let phone = storedPhoneNumber(keeper.phone);
let position = keeper.position;
let website = keeper.website;
let instagram = keeper.instagram;
let birthDate = keeper.birthDate;
let referrer = keeper.referrer;
let companyId = keeper.companyId;
let gender = keeper.gender;
let showGenderInName = keeper.showGenderInName;
let previousFirstName = keeper.previousFirstName;
let previousLastName = keeper.previousLastName;
let previousImportSource = keeper.previousImportSource;
let previousSourceRef = keeper.previousSourceRef;
let discLabel = keeper.discLabel;
let discReliable = keeper.discReliable;
let discAssessedAt = keeper.discAssessedAt;
let loyaltyPoints = keeper.loyaltyPoints;
const addresses = asStrings(keeper.addresses);
const tags = new Set(keeper.tags);
const customFields = asObject(keeper.customFields);
for (const extra of extras) {
const name = `${extra.firstName} ${extra.lastName}`.trim();
notes.push(
`ادغام شماره تکراری: ${name || 'بدون نام'} (${extra.mobile || 'بدون موبایل'})`,
);
if (blank(email) && extra.email) email = extra.email;
if (blank(phone)) phone = storedPhoneNumber(extra.phone);
if (blank(position) && extra.position) position = extra.position;
if (blank(website) && extra.website) website = extra.website;
if (blank(instagram) && extra.instagram) instagram = extra.instagram;
if (!birthDate && extra.birthDate) birthDate = extra.birthDate;
if (blank(referrer) && extra.referrer) referrer = extra.referrer;
if (!companyId && extra.companyId) companyId = extra.companyId;
if (gender === 'none' && extra.gender !== 'none') {
gender = extra.gender;
showGenderInName = extra.showGenderInName;
}
if (blank(previousFirstName) && extra.previousFirstName) {
previousFirstName = extra.previousFirstName;
}
if (blank(previousLastName) && extra.previousLastName) {
previousLastName = extra.previousLastName;
}
if (
blank(previousImportSource) &&
extra.importSource &&
extra.importSource !== keeper.importSource
) {
previousImportSource = extra.importSource;
previousSourceRef = extra.sourceRef;
}
if (
keeper.importSource === 'tahesab' &&
blank(previousFirstName) &&
blank(previousLastName)
) {
const extraName = `${extra.firstName} ${extra.lastName}`.trim();
const keeperName = `${keeper.firstName} ${keeper.lastName}`.trim();
if (extraName && extraName !== keeperName) {
previousFirstName = extra.firstName || null;
previousLastName = extra.lastName || null;
}
}
if (!discLabel && extra.discLabel) {
discLabel = extra.discLabel;
discReliable = extra.discReliable;
discAssessedAt = extra.discAssessedAt;
}
loyaltyPoints = Math.max(loyaltyPoints, extra.loyaltyPoints);
for (const address of asStrings(extra.addresses)) {
if (!addresses.includes(address)) addresses.push(address);
}
for (const tag of extra.tags) tags.add(tag);
for (const [key, value] of Object.entries(asObject(extra.customFields))) {
if (customFields[key] == null || customFields[key] === '') customFields[key] = value;
}
}
return {
mobile: canon,
email,
phone,
position,
website,
instagram,
birthDate,
referrer,
companyId,
gender,
showGenderInName,
previousFirstName,
previousLastName,
previousImportSource,
previousSourceRef,
discLabel,
discReliable,
discAssessedAt,
loyaltyPoints,
addresses,
tags: [...tags],
customFields: customFields as Prisma.InputJsonValue,
notes: notes.filter(Boolean).join('\n') || null,
};
}
async function personIdTables(prisma: PrismaClient | Prisma.TransactionClient) {
const rows = await prisma.$queryRaw<{ table_name: string }[]>`
SELECT table_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'personId'
`;
return rows
.map((row) => row.table_name)
.filter((name) => /^[A-Za-z0-9_]+$/.test(name) && name !== 'Person');
}
async function moveLinks(
tx: Prisma.TransactionClient,
tables: string[],
keeperId: string,
dupId: string,
) {
await tx.$executeRaw`
DELETE FROM "PersonGroupLink" AS d
USING "PersonGroupLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."groupId" = k."groupId"
`;
await tx.$executeRaw`
DELETE FROM "PersonAccountingLink" AS d
USING "PersonAccountingLink" AS k
WHERE d."personId" = ${dupId}
AND k."personId" = ${keeperId}
AND d."connectionId" = k."connectionId"
`;
await tx.$executeRaw`
DELETE FROM "PartyOpeningBalance"
WHERE "personId" = ${dupId}
AND EXISTS (
SELECT 1 FROM "PartyOpeningBalance" AS k WHERE k."personId" = ${keeperId}
)
`;
for (const table of tables) {
await tx.$executeRawUnsafe(
`UPDATE "${table}" SET "personId" = $1 WHERE "personId" = $2`,
keeperId,
dupId,
);
}
await tx.$executeRaw`
UPDATE "Lead" SET "convertedPersonId" = ${keeperId} WHERE "convertedPersonId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "MontifyLink"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'customer' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "Notification"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "GamificationTransaction"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
await tx.$executeRaw`
UPDATE "FinancialAuditLog"
SET "entityId" = ${keeperId}
WHERE "entityType" = 'person' AND "entityId" = ${dupId}
`;
}
export async function mergeSameMobilePeople(
prisma: PrismaClient,
organizationId: string,
apply: boolean,
) {
const people = await prisma.person.findMany({
where: { organizationId },
select: personSelect,
orderBy: { createdAt: 'asc' },
});
const groups = new Map<string, PersonRow[]>();
for (const person of people) {
const key = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
if (!key) continue;
const list = groups.get(key) ?? [];
list.push(person);
groups.set(key, list);
}
const duplicateGroups = [...groups.entries()].filter(([, list]) => list.length > 1);
const preview = duplicateGroups.map(([mobile, list]) => ({
mobile,
people: list.map((person) => ({
id: person.id,
stored: person.mobile,
name: `${person.firstName} ${person.lastName}`.trim(),
})),
}));
if (!apply) {
const needsRewrite = people.filter((person) => {
const canon = canonicalIranMobile(person.mobile) ?? canonicalIranMobile(person.phone);
return Boolean(canon && (person.mobile !== canon || canonicalIranMobile(person.phone)));
}).length;
return { merged: 0, rewritten: 0, needsRewrite, failed: 0, groups: preview };
}
const tables = await personIdTables(prisma);
let merged = 0;
let rewritten = 0;
let failed = 0;
for (const [canon, list] of groups) {
const ordered = [...list].sort((a, b) => {
const aTahesab = a.importSource === 'tahesab' ? 0 : 1;
const bTahesab = b.importSource === 'tahesab' ? 0 : 1;
if (aTahesab !== bTahesab) return aTahesab - bTahesab;
return a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id);
});
const [keeper, ...extras] = ordered;
if (!keeper) continue;
if (!extras.length) {
const phoneCanon = canonicalIranMobile(keeper.phone);
const data: Prisma.PersonUpdateInput = {};
if (keeper.mobile !== canon) data.mobile = canon;
if (phoneCanon && keeper.phone !== phoneCanon) data.phone = phoneCanon;
if (Object.keys(data).length) {
await prisma.person.update({ where: { id: keeper.id }, data });
rewritten += 1;
}
continue;
}
try {
await prisma.$transaction(
async (tx) => {
await tx.person.update({
where: { id: keeper.id },
data: mergedFields(keeper, extras, canon),
});
for (const extra of extras) {
await moveLinks(tx, tables, keeper.id, extra.id);
await tx.person.delete({ where: { id: extra.id } });
merged += 1;
}
},
{ timeout: 60_000 },
);
} catch (error) {
failed += 1;
console.error(
`merge mobile ${canon} failed`,
error instanceof Error ? error.message : error,
);
}
}
return { merged, rewritten, needsRewrite: 0, failed, groups: preview };
}
function loadEnv(file: string) {
const text = readFileSync(file, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_]+)=(.*)$/);
if (!match || process.env[match[1]]) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[match[1]] = value;
}
}
const direct = process.argv[1]?.replace(/\\/g, '/').includes('merge-same-mobile');
if (direct) {
loadEnv(resolve(__dirname, '../../.env'));
const orgId = process.argv.find((arg) => arg.startsWith('--org='))?.slice(6);
const apply = process.argv.includes('--apply');
if (!orgId) {
console.error('org id required: --org=<id>');
process.exit(1);
}
const prisma = new PrismaClient();
mergeSameMobilePeople(prisma, orgId, apply)
.then((result) => {
console.log(JSON.stringify({
apply,
merged: result.merged,
rewritten: result.rewritten,
needsRewrite: result.needsRewrite,
groupCount: result.groups.length,
groups: result.groups,
}, null, 2));
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
+33
View File
@@ -27,3 +27,36 @@ export function normalizePhone(value?: string | null): string | null {
const cleaned = latinDigits(v).replace(/[^\d+]/g, ''); const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null; return cleaned.length ? cleaned : null;
} }
/** موبایل ایران به شکل 09xxxxxxxxx. 912… و 98912… و +98912… همان شماره‌اند. */
export function canonicalIranMobile(value?: string | null): string | null {
const n = normalizePhone(value);
if (!n) return null;
let d = n.replace(/\D/g, '');
if (!d) return null;
if (d.startsWith('0098') && d.length >= 14) d = d.slice(4);
else if (d.startsWith('98') && d.length >= 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** شکل‌هایی که ممکن است همین موبایل با آن‌ها ذخیره شده باشد. */
export function iranMobileLookupKeys(value?: string | null): string[] {
const canon = canonicalIranMobile(value);
if (!canon) return [];
const core = canon.slice(1);
return [canon, core, `98${core}`, `+98${core}`, `0098${core}`];
}
/** برای تطبیق: اگر موبایل ایران است همه شکل‌ها، وگرنه همان متن نرمال‌شده. */
export function phoneIdentityKeys(value?: string | null): string[] {
const keys = iranMobileLookupKeys(value);
if (keys.length) return keys;
const n = normalizePhone(value);
return n ? [n] : [];
}
/** ذخیره: موبایل ایران همیشه 09… ؛ بقیه شماره‌ها فقط تمیز می‌شوند. */
export function storedPhoneNumber(value?: string | null): string | null {
return canonicalIranMobile(value) ?? normalizePhone(value);
}
+18 -7
View File
@@ -28,7 +28,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'; import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize'; import {
emptyToNull,
normalizeEmail,
phoneIdentityKeys,
storedPhoneNumber,
} from './normalize';
import { import {
asAddresses, asAddresses,
asCustomFields, asCustomFields,
@@ -399,7 +404,10 @@ export class PeopleService implements OnModuleInit {
input: { mobile?: string | null; email?: string | null; birthDate: Date }, input: { mobile?: string | null; email?: string | null; birthDate: Date },
) { ) {
const or: Prisma.PersonWhereInput[] = []; 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 (input.email) or.push({ email: input.email });
if (!or.length) return; if (!or.length) return;
await this.prisma.person.updateMany({ await this.prisma.person.updateMany({
@@ -441,8 +449,8 @@ export class PeopleService implements OnModuleInit {
importSource: normalizePersonImportSource(dto.importSource), importSource: normalizePersonImportSource(dto.importSource),
sourceRef: emptyToNull(dto.sourceRef), sourceRef: emptyToNull(dto.sourceRef),
email: normalizeEmail(dto.email), email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone), phone: storedPhoneNumber(dto.phone),
mobile: normalizePhone(dto.mobile), mobile: storedPhoneNumber(dto.mobile),
position: emptyToNull(dto.position), position: emptyToNull(dto.position),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
@@ -553,8 +561,8 @@ export class PeopleService implements OnModuleInit {
} }
if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef); if ('sourceRef' in dto) data.sourceRef = emptyToNull(dto.sourceRef);
if ('email' in dto) data.email = normalizeEmail(dto.email); if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone); if ('phone' in dto) data.phone = storedPhoneNumber(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); if ('mobile' in dto) data.mobile = storedPhoneNumber(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position); if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website); if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); 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 email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null; const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const mobileKeys = phoneIdentityKeys(mobile);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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; if (!or.length) return;
const clash = await this.prisma.person.findFirst({ 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'; } from '@mento/shared';
import { parseBirthDate } from '../../common/excel'; import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service'; 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 { carryPreviousSource } from '../../contacts/person-source';
import { ownerWhere, type Actor } from '../../common/record-scope'; import { ownerWhere, type Actor } from '../../common/record-scope';
import { MontifyConnectionsService } from './connections.service'; import { MontifyConnectionsService } from './connections.service';
@@ -100,13 +104,7 @@ function arr(v: unknown): unknown[] {
} }
function mobileVariants(mobile: string): string[] { function mobileVariants(mobile: string): string[] {
const digits = mobile.replace(/\D/g, ''); return phoneIdentityKeys(mobile);
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}`])];
} }
function formatAddress(a: MontifyAddress): string | null { function formatAddress(a: MontifyAddress): string | null {
@@ -417,7 +415,7 @@ export class MontifySyncService {
}> { }> {
const externalId = String(c.id); const externalId = String(c.id);
const { firstName, lastName } = splitName(c); const { firstName, lastName } = splitName(c);
const mobile = normalizePhone(c.phone ?? null); const mobile = storedPhoneNumber(c.phone ?? null);
const email = normalizeEmail(c.email ?? null); const email = normalizeEmail(c.email ?? null);
const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a)); const addressLines = addresses.map(formatAddress).filter((a): a is string => Boolean(a));
const profile: CustomerProfile = { const profile: CustomerProfile = {
@@ -43,6 +43,10 @@ export class TahesabConnectionsService {
lastHealthAt: Date | null; lastHealthAt: Date | null;
lastHealthOk: boolean | null; lastHealthOk: boolean | null;
lastError: string | null; lastError: string | null;
customerSyncHours: number[];
customerSyncLastRunAt: Date | null;
customerSyncLastSummary: string | null;
customerSyncLastError: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}) { }) {
@@ -60,6 +64,10 @@ export class TahesabConnectionsService {
lastHealthAt: row.lastHealthAt?.toISOString() ?? null, lastHealthAt: row.lastHealthAt?.toISOString() ?? null,
lastHealthOk: row.lastHealthOk, lastHealthOk: row.lastHealthOk,
lastError: row.lastError, 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(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}; };
@@ -201,6 +209,15 @@ export class TahesabConnectionsService {
if (dto.isDefault === true) await this.clearOtherDefaults(organizationId, id); 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 = { let enc = {
ciphertext: row.tokenCiphertext, ciphertext: row.tokenCiphertext,
iv: row.tokenIv, iv: row.tokenIv,
@@ -225,6 +242,7 @@ export class TahesabConnectionsService {
enabled: dto.enabled ?? row.enabled, enabled: dto.enabled ?? row.enabled,
isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault, isDefault: dto.isDefault === true ? true : dto.isDefault === false ? false : row.isDefault,
allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls, allowInsecureTls: dto.allowInsecureTls ?? row.allowInsecureTls,
...(customerSyncHours !== undefined ? { customerSyncHours } : {}),
}, },
}); });
return this.toDto(updated); return this.toDto(updated);
@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabPersonLinkService } from './person-link.service';
/** ساعت و تاریخ امروز به وقت تهران، برای نوبت یک‌بار در هر ساعت. */
export function tehranSyncSlot(now = new Date()): { hour: number; slot: string } {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const read = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
let hour = Number(read('hour'));
if (!Number.isFinite(hour) || hour === 24) hour = 0;
const slot = `${read('year')}-${read('month')}-${read('day')}-${String(hour).padStart(2, '0')}`;
return { hour, slot };
}
@Injectable()
export class TahesabCustomerSyncScheduler {
private readonly logger = new Logger(TahesabCustomerSyncScheduler.name);
private busy = false;
constructor(
private readonly prisma: PrismaService,
private readonly links: TahesabPersonLinkService,
) {}
@Cron('* * * * *', { timeZone: 'Asia/Tehran' })
async tick() {
if (process.env.NODE_ENV === 'test' || this.busy) return;
this.busy = true;
try {
const { hour, slot } = tehranSyncSlot();
const connections = await this.prisma.accountingConnection.findMany({
where: {
provider: 'tahesab',
enabled: true,
customerSyncHours: { has: hour },
},
select: { id: true, organizationId: true, label: true, customerSyncLastSlot: true },
});
for (const connection of connections) {
if (connection.customerSyncLastSlot === slot) continue;
const claimed = await this.prisma.accountingConnection.updateMany({
where: {
id: connection.id,
OR: [
{ customerSyncLastSlot: null },
{ customerSyncLastSlot: { not: slot } },
],
},
data: { customerSyncLastSlot: slot },
});
if (claimed.count !== 1) continue;
await this.run(connection.organizationId, connection.id, connection.label);
}
} catch (err) {
this.logger.warn(
`بررسی زمان‌بندی مشتریان ته‌حساب ناموفق بود: ${
err instanceof Error ? err.message : err
}`,
);
} finally {
this.busy = false;
}
}
private async run(organizationId: string, connectionId: string, label: string) {
try {
const result = await this.links.syncAllCustomers(organizationId, connectionId);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: result.summary,
customerSyncLastError: result.error,
},
});
this.logger.log(`بروزرسانی خودکار «${label}»: ${result.summary}`);
} catch (err) {
const message = (err instanceof Error ? err.message : 'همگام‌سازی ناموفق بود').slice(0, 500);
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: null,
customerSyncLastError: message,
},
});
this.logger.warn(`بروزرسانی خودکار «${label}» ناموفق بود: ${message}`);
}
}
}
+10
View File
@@ -86,6 +86,16 @@ export class UpdateTahesabConnectionDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
allowInsecureTls?: boolean; allowInsecureTls?: boolean;
/** ساعت‌های ۰ تا ۲۳. آرایهٔ خالی یعنی خاموش. */
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@Type(() => Number)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(23, { each: true })
customerSyncHours?: number[];
} }
export class ManualLinkDto { export class ManualLinkDto {
@@ -8,7 +8,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; 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 { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service'; import { TahesabConnectionsService } from './connections.service';
import { import {
@@ -25,6 +30,20 @@ import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; 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 */ /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
const LIST_PAGE_SIZE = 2000; const LIST_PAGE_SIZE = 2000;
const LIST_TIMEOUT_MS = 55_000; const LIST_TIMEOUT_MS = 55_000;
@@ -539,6 +558,7 @@ export class TahesabPersonLinkService {
organizationId: string, organizationId: string,
dto: ImportFromTahesabDto, dto: ImportFromTahesabDto,
prefetched?: { api: TahesabApi; row?: Record<string, unknown> }, prefetched?: { api: TahesabApi; row?: Record<string, unknown> },
options?: { skipJob?: boolean },
) { ) {
const clash = await this.prisma.personAccountingLink.findFirst({ const clash = await this.prisma.personAccountingLink.findFirst({
where: { where: {
@@ -577,10 +597,11 @@ export class TahesabPersonLinkService {
const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName; const firstName = named.firstName;
const lastName = named.lastName; const lastName = named.lastName;
const mobile = tel || null; const mobile = storedPhoneNumber(tel);
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -604,28 +625,33 @@ export class TahesabPersonLinkService {
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile ...canonicalMobileUpdate(mobile, existing),
? {
mobile: existing.mobile || mobile,
phone: existing.phone || mobile,
}
: {}),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
const byMobile = mobile const mobileKeys = phoneIdentityKeys(mobile);
? await this.prisma.person.findFirst({ const matches = mobileKeys.length
? await this.prisma.person.findMany({
where: { where: {
organizationId, organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
accountingLinks: { none: { connectionId: dto.connectionId } },
}, },
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) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -644,8 +670,9 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile),
}, },
}); });
personId = byMobile.id; 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({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -693,18 +746,169 @@ export class TahesabPersonLinkService {
}); });
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId: dto.connectionId, connectionId,
kind: 'import', kind: 'customer-sync',
status: 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
request: dto as unknown as Prisma.InputJsonValue, result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { personId, linkId: link.id } as Prisma.InputJsonValue,
}, },
}); });
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { personId, link: this.linkDto(link) }; return {
imported,
updated,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
};
} }
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
@@ -12,6 +12,7 @@ import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service'; import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service'; import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabCustomerSyncScheduler } from './customer-sync.scheduler';
import { TahesabController } from './tahesab.controller'; import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module'; import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -29,6 +30,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabDocumentsService, TahesabDocumentsService,
TahesabAsnadReportService, TahesabAsnadReportService,
TahesabInvoiceSettingsService, TahesabInvoiceSettingsService,
TahesabCustomerSyncScheduler,
], ],
exports: [ exports: [
TahesabCryptoService, TahesabCryptoService,
@@ -11,7 +11,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
@@ -35,6 +35,7 @@ function phoneVariants(raw: string | null | undefined): string[] {
variants.add(`0${d}`); variants.add(`0${d}`);
variants.add(`98${d}`); variants.add(`98${d}`);
} }
for (const key of iranMobileLookupKeys(raw)) variants.add(key);
return [...variants]; return [...variants];
} }
@@ -6,7 +6,11 @@ import type {
WooCommerceSyncResultDto, WooCommerceSyncResultDto,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service'; 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 { WooConnectionsService } from './connections.service';
import type { WooOrder } from './woo-client'; import type { WooOrder } from './woo-client';
@@ -374,7 +378,8 @@ export class WooSyncService {
): Promise<string | null> { ): Promise<string | null> {
const billing = order.billing ?? {}; const billing = order.billing ?? {};
const email = normalizeEmail(billing.email ?? null); 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 firstName = (billing.first_name ?? '').trim() || 'مشتری';
const lastName = (billing.last_name ?? '').trim() || 'ووکامرس'; const lastName = (billing.last_name ?? '').trim() || 'ووکامرس';
@@ -387,7 +392,9 @@ export class WooSyncService {
organizationId, organizationId,
OR: [ OR: [
...(email ? [{ email }] : []), ...(email ? [{ email }] : []),
...(mobile ? [{ mobile }, { phone: mobile }] : []), ...(mobileKeys.length
? [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }]
: []),
], ],
}, },
select: { id: true }, select: { id: true },
+16 -8
View File
@@ -21,9 +21,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { import {
canonicalIranMobile,
emptyToNull, emptyToNull,
normalizeEmail, normalizeEmail,
normalizePhone, normalizePhone,
phoneIdentityKeys,
storedPhoneNumber,
} from '../contacts/normalize'; } from '../contacts/normalize';
import { LeadStagesService } from '../leads/lead-stages.service'; import { LeadStagesService } from '../leads/lead-stages.service';
@@ -458,7 +461,10 @@ export class LeadFormsService {
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { where: {
organizationId: form.organizationId, organizationId: form.organizationId,
OR: [{ mobile }, { phone: mobile }], OR: [
{ mobile: { in: phoneIdentityKeys(mobile) } },
{ phone: { in: phoneIdentityKeys(mobile) } },
],
}, },
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}) })
@@ -641,13 +647,16 @@ export class LeadFormsService {
ownerId: string, ownerId: string,
): Promise<string> { ): Promise<string> {
const email = normalizeEmail(mapped.email); const email = normalizeEmail(mapped.email);
const mobile = normalizePhone(mapped.mobile); const mobile = storedPhoneNumber(mapped.mobile);
const phone = normalizePhone(mapped.phone); const phone = storedPhoneNumber(mapped.phone);
const or: Prisma.PersonWhereInput[] = []; const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email }); 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 const existing = or.length
? await this.prisma.person.findFirst({ ? await this.prisma.person.findFirst({
where: { organizationId, OR: or }, where: { organizationId, OR: or },
@@ -1115,13 +1124,12 @@ function slugKey(label: string): string {
} }
function mobileKey(raw: string | null | undefined): string | null { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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;
} }
function normalizeDuplicateMode(raw?: string | null): string { function normalizeDuplicateMode(raw?: string | null): string {
+140 -28
View File
@@ -32,12 +32,18 @@ import {
forScope, forScope,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
import { import {
asAddresses, asAddresses,
collectAddresses, collectAddresses,
toJsonAddresses, toJsonAddresses,
} from '../common/custom-fields'; } 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 { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.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 { function mobileKey(raw: string | null | undefined): string | null {
const canon = canonicalIranMobile(raw);
if (canon) return canon;
const n = normalizePhone(raw); const n = normalizePhone(raw);
if (!n) return null; if (!n) return null;
const d = n.replace(/\D/g, ''); const d = n.replace(/\D/g, '');
if (!d) return null; return d || 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; 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 { function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
@@ -322,10 +335,7 @@ export class LeadsService {
}; };
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, mobile),
organizationId: actor.organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true, firstName: true, lastName: true }, select: { id: true, firstName: true, lastName: true },
}); });
if (person) { if (person) {
@@ -613,24 +623,43 @@ export class LeadsService {
}); });
if (!stage) throw new NotFoundException('مرحله پیدا نشد'); 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({ const row = await this.prisma.lead.update({
where: { id }, where: { id },
data: { data: {
stageId, stageId,
position: position ?? 0, position: nextPosition,
...(stage.isConverted
? {}
: {}),
}, },
include: leadInclude, include: leadInclude,
}); });
await this.addEvent( if (placedIds) {
actor, await writePositions(
id, (ops) => this.prisma.$transaction(ops),
'stage_changed', placedIds,
`به مرحله «${stage.name}» منتقل شد`, (leadId, leadPosition) =>
); this.prisma.lead.update({ where: { id: leadId }, data: { position: leadPosition } }),
);
}
if (before.stageId !== stageId) { if (before.stageId !== stageId) {
await this.addEvent(
actor,
id,
'stage_changed',
`به مرحله «${stage.name}» منتقل شد`,
);
this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, { this.gameEvent(actor, 'LEAD_STAGE_CHANGED', row, {
eventKey: `${id}:${stageId}`, eventKey: `${id}:${stageId}`,
facts: { stageName: stage.name, source: row.source }, facts: { stageName: stage.name, source: row.source },
@@ -946,10 +975,7 @@ export class LeadsService {
} }
let person = await this.prisma.person.findFirst({ let person = await this.prisma.person.findFirst({
where: { where: personWithMobile(actor.organizationId, lead.mobile),
organizationId: actor.organizationId,
OR: [{ mobile: lead.mobile }, { phone: lead.mobile }],
},
}); });
let createdPerson = false; let createdPerson = false;
@@ -976,7 +1002,7 @@ export class LeadsService {
lastName: named.lastName, lastName: named.lastName,
gender: named.gender, gender: named.gender,
showGenderInName: named.showGenderInName, showGenderInName: named.showGenderInName,
mobile: lead.mobile, mobile: canonicalIranMobile(lead.mobile) ?? lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate, 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( async createFromImportRow(
organizationId: string, organizationId: string,
createdById: string | null, createdById: string | null,
@@ -1192,10 +1307,7 @@ export class LeadsService {
} }
const existingPerson = await this.prisma.person.findFirst({ const existingPerson = await this.prisma.person.findFirst({
where: { where: personWithMobile(organizationId, mobile),
organizationId,
OR: [{ mobile }, { phone: mobile }],
},
select: { id: true }, select: { id: true },
}); });
if (existingPerson) { if (existingPerson) {
+21
View File
@@ -2,6 +2,7 @@ import { Type } from 'class-transformer';
import { import {
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -45,6 +46,16 @@ export class CreateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class UpdateOrgRoleDto { export class UpdateOrgRoleDto {
@@ -77,6 +88,16 @@ export class UpdateOrgRoleDto {
@ValidateIf((_, v) => v !== null) @ValidateIf((_, v) => v !== null)
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
treasuryAccountIds?: string[];
@IsOptional()
@IsBoolean()
treasuryOwnAccounts?: boolean;
} }
export class RolePermissionPatchItemDto { export class RolePermissionPatchItemDto {
@@ -32,6 +32,8 @@ type RoleRow = {
color: string; color: string;
permissions: string[]; permissions: string[];
dataAccessPolicy?: unknown; dataAccessPolicy?: unknown;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
systemKey: string | null; systemKey: string | null;
parentId: string | null; parentId: string | null;
createdAt: Date; createdAt: Date;
@@ -79,6 +81,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -108,6 +112,8 @@ export class OrgRolesService {
description: (dto.description ?? '').trim(), description: (dto.description ?? '').trim(),
color: dto.color ?? parent?.color ?? 'teal', color: dto.color ?? parent?.color ?? 'teal',
permissions, permissions,
treasuryAccountIds: dto.treasuryAccountIds ?? [],
treasuryOwnAccounts: dto.treasuryOwnAccounts ?? false,
parentId, parentId,
...(dto.dataAccessPolicy ...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) } ? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
@@ -135,6 +141,8 @@ export class OrgRolesService {
permissions?: RolePermissionKey[]; permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy; dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null; parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
}, },
): Promise<OrgRoleDto> { ): Promise<OrgRoleDto> {
this.assertManager(actorRole); this.assertManager(actorRole);
@@ -159,6 +167,12 @@ export class OrgRolesService {
if (dto.dataAccessPolicy !== undefined) { if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy); 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) { if (dto.parentId !== undefined) {
const nextParentId = const nextParentId =
@@ -426,6 +440,8 @@ export class OrgRolesService {
color: row.color, color: row.color,
permissions: sanitizePermissions(row.permissions), permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy), dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
treasuryAccountIds: row.treasuryAccountIds ?? [],
treasuryOwnAccounts: row.treasuryOwnAccounts ?? false,
systemKey, systemKey,
parentId: row.parentId, parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null, 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 { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive'; import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields'; import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -708,9 +708,9 @@ export class DealsExcelService {
): Promise<string | null> { ): Promise<string | null> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const code = cleanCode(row.personCode); const code = cleanCode(row.personCode);
const mobile = normalizePhone(row.personMobile); const mobile = storedPhoneNumber(row.personMobile);
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : ''; const mobileKeys = phoneIdentityKeys(mobile);
const validMobile = mobileCore.length >= 8 ? mobile : null; const validMobile = (mobile ?? '').replace(/\D/g, '').length >= 8 ? mobile : null;
const first = (row.personFirstName ?? '').trim(); const first = (row.personFirstName ?? '').trim();
const last = (row.personName ?? '').trim(); const last = (row.personName ?? '').trim();
const fullName = [first, last].filter(Boolean).join(' '); const fullName = [first, last].filter(Boolean).join(' ');
@@ -731,7 +731,10 @@ export class DealsExcelService {
} }
if (!found && validMobile) { if (!found && validMobile) {
found = await this.prisma.person.findFirst({ 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 }, select: { id: true },
}); });
} }
+23 -1
View File
@@ -27,6 +27,7 @@ import {
resolveOwnerId, resolveOwnerId,
} from '../common/record-scope'; } from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { placeId, sameIds, writePositions } from '../common/column-order';
const dealInclude = { const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } }, 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 status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
const position = let placedIds: string[] | null = null;
let position =
dto.position ?? dto.position ??
(( ((
await this.prisma.deal.aggregate({ await this.prisma.deal.aggregate({
@@ -387,6 +389,17 @@ export class DealsService {
_max: { position: true }, _max: { position: true },
}) })
)._max.position ?? -1) + 1; )._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({ const row = await this.prisma.deal.update({
where: { id }, where: { id },
@@ -402,6 +415,15 @@ export class DealsService {
include: dealInclude, 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) { if (deal.ownerId && deal.stageId !== stage.id) {
await this.notifications.create({ await this.notifications.create({
organizationId, organizationId,
+13 -6
View File
@@ -19,6 +19,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared'; import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service'; import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import type { PortalActor } from './portal-auth'; import type { PortalActor } from './portal-auth';
@@ -62,7 +63,7 @@ export class PortalService {
mobileRaw: string, mobileRaw: string,
): Promise<PortalRequestOtpResponse> { ): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
if (!mobile) { if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست'); throw new BadRequestException('شماره موبایل معتبر نیست');
} }
@@ -123,7 +124,8 @@ export class PortalService {
codeRaw: string, codeRaw: string,
): Promise<PortalLoginResponse> { ): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug); const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw);
const mobileKeys = phoneIdentityKeys(mobile);
const code = codeRaw.trim(); const code = codeRaw.trim();
if (!mobile || !code) { if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است'); throw new BadRequestException('موبایل یا کد ناقص است');
@@ -148,10 +150,15 @@ export class PortalService {
data: { consumedAt: new Date() }, data: { consumedAt: new Date() },
}); });
let person = await this.prisma.person.findFirst({ let person = mobileKeys.length
where: { organizationId: org.id, mobile }, ? await this.prisma.person.findFirst({
orderBy: { createdAt: 'asc' }, where: {
}); organizationId: org.id,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
orderBy: { createdAt: 'asc' },
})
: null;
if (!person) { if (!person) {
person = await this.prisma.person.create({ person = await this.prisma.person.create({
data: { data: {
@@ -3,7 +3,7 @@ import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { projectCardAccessWhere } from '../common/record-scope'; import { projectCardAccessWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize'; import { phoneIdentityKeys, storedPhoneNumber } from '../contacts/normalize';
import { import {
excelFieldsFromColumns, excelFieldsFromColumns,
formatJalaliDate, formatJalaliDate,
@@ -256,11 +256,13 @@ export class ProjectCardsExcelService {
} }
private async findPersonId(organizationId: string, row: Record<string, string>) { private async findPersonId(organizationId: string, row: Record<string, string>) {
const mobile = normalizePhone(row.personMobile); const mobileKeys = phoneIdentityKeys(storedPhoneNumber(row.personMobile));
if (mobile) { if (mobileKeys.length) {
const needle = mobile.replace(/^\+98|^98|^0/, '');
const byMobile = await this.prisma.person.findFirst({ const byMobile = await this.prisma.person.findFirst({
where: { organizationId, mobile: { contains: needle } }, where: {
organizationId,
OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }],
},
select: { id: true }, select: { id: true },
}); });
if (byMobile) return byMobile.id; if (byMobile) return byMobile.id;
@@ -22,6 +22,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: string; organizationId: string;
role: string; role: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyName?: string;
}; };
}>(); }>();
@@ -44,6 +45,7 @@ export class ApiKeyAuthGuard implements CanActivate {
organizationId: auth.organizationId, organizationId: auth.organizationId,
role: auth.role, role: auth.role,
apiKeyId: auth.apiKeyId, apiKeyId: auth.apiKeyId,
apiKeyName: auth.name,
}; };
return true; return true;
} }
+3 -2
View File
@@ -63,7 +63,7 @@ export class ApiKeysService {
async authenticate( async authenticate(
rawKey: string, rawKey: string,
): Promise<{ organizationId: string; apiKeyId: string; role: 'owner' }> { ): Promise<{ organizationId: string; apiKeyId: string; name: string; role: 'owner' }> {
const key = rawKey.trim(); const key = rawKey.trim();
if (!key.startsWith('mk_') || key.length < 20) { if (!key.startsWith('mk_') || key.length < 20) {
throw new UnauthorizedException('کلید API نامعتبر است'); throw new UnauthorizedException('کلید API نامعتبر است');
@@ -71,7 +71,7 @@ export class ApiKeysService {
const keyHash = this.hashKey(key); const keyHash = this.hashKey(key);
const row = await this.prisma.organizationApiKey.findFirst({ const row = await this.prisma.organizationApiKey.findFirst({
where: { keyHash, revokedAt: null }, where: { keyHash, revokedAt: null },
select: { id: true, organizationId: true }, select: { id: true, organizationId: true, name: true },
}); });
if (!row) throw new UnauthorizedException('کلید API نامعتبر است'); if (!row) throw new UnauthorizedException('کلید API نامعتبر است');
@@ -83,6 +83,7 @@ export class ApiKeysService {
return { return {
organizationId: row.organizationId, organizationId: row.organizationId,
apiKeyId: row.id, apiKeyId: row.id,
name: row.name,
role: 'owner', role: 'owner',
}; };
} }
@@ -1,18 +1,55 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpException,
HttpStatus,
Param, Param,
Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { Type } from 'class-transformer';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { LeadsService } from '../leads/leads.service';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; 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 { class ListQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -35,7 +72,36 @@ class ListQueryDto {
@Controller('public/v1') @Controller('public/v1')
@UseGuards(ApiKeyAuthGuard) @UseGuards(ApiKeyAuthGuard)
export class PublicApiV1Controller { 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') @Get('people')
async listPeople( async listPeople(
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { LeadsModule } from '../leads/leads.module';
import { ApiKeyAuthGuard } from './api-key-auth.guard'; import { ApiKeyAuthGuard } from './api-key-auth.guard';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
@@ -9,7 +10,7 @@ import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service'; import { WebhooksService } from './webhooks.service';
@Module({ @Module({
imports: [AuthModule, BillingModule], imports: [AuthModule, BillingModule, LeadsModule],
controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller], controllers: [ApiKeysController, WebhooksController, PublicApiV1Controller],
providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService], providers: [ApiKeysService, ApiKeyAuthGuard, WebhooksService],
exports: [ApiKeysService, WebhooksService], exports: [ApiKeysService, WebhooksService],
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink), entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore, reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive, isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule), recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total, executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> { async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope); const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({ const row = await this.prisma.routine.create({
data: { data: {
...data, ...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> { async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
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.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput, data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> { async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto, dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> { ): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); 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); const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین'); await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({ const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> { async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id); const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src); this.assertCanWriteRoutine(scope, src);
const { const {
id: _id, id: _id,
createdAt: _c, createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) { async remove(actor: Actor, id: string) {
const scope = await this.scope(actor); const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id); const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing); this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({ await this.prisma.routine.update({
where: { id }, where: { id },
data: { deletedAt: new Date(), isActive: false }, data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row; 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( private assertCanManageRoutine(
scope: RoutineScope, scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
+54 -9
View File
@@ -1,7 +1,16 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 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 type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
/**
* حقوق فقط با treasury_payroll.
* بقیه مسیرها با treasury باز می‌شوند؛ اگر بخش ریز انتخاب شده باشد فقط همان‌ها.
*/
@Injectable() @Injectable()
export class TreasuryAccessGuard implements CanActivate { export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} 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 req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const actor = req.user; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); 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; if (actor.role === 'owner' || actor.role === 'admin') return true;
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, 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) ? membership.orgRole.permissions.map(String)
: []; : [];
const allowed = payroll const permissions = permissionsForTreasury(
? permissions.includes('treasury_payroll') rolePermissions,
: permissions.includes('treasury') || permissions.includes('treasury_payroll'); parseUserTreasuryAccess(membership?.treasuryAccessOverride),
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.'); );
const url = req.originalUrl || req.url || '';
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
return true; 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';
}
+38 -1
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
@@ -314,7 +315,12 @@ export class TreasuryOpsService {
return { from: from.toISOString(), to: to.toISOString(), current, previous, ...breakdown }; 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 data = await this.report(actor, from, to);
const book = new ExcelJS.Workbook(); const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('گزارش مالی'); const sheet = book.addWorksheet('گزارش مالی');
@@ -331,10 +337,41 @@ export class TreasuryOpsService {
sheet.addRow([]); sheet.addRow([]);
sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']); sheet.addRow(['دسته هزینه', 'مبلغ', 'سهم']);
data.current.expenseCategories.forEach((row) => sheet.addRow([row.name, row.amount, row.share])); 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(); const buffer = await book.xlsx.writeBuffer();
return Buffer.from(buffer); 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) { private async period(organizationId: string, from: Date, to: Date) {
const docs = await this.prisma.financialDocument.findMany({ const docs = await this.prisma.financialDocument.findMany({
where: { where: {
+55 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body); 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') @Get('accounts')
accounts( accounts(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -61,7 +75,18 @@ export class TreasuryController {
updateAccount( updateAccount(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@Param('id') id: string, @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); return this.treasury.updateAccount(user, id, body);
} }
@@ -81,6 +106,29 @@ export class TreasuryController {
return this.treasury.transfer(user, body); 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') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -221,7 +269,11 @@ export class TreasuryController {
@Query('from') from?: string, @Query('from') from?: string,
@Query('to') to?: 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, { return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="treasury-report.xlsx"', disposition: 'attachment; filename="treasury-report.xlsx"',
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ContactsModule } from '../contacts/contacts.module';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { TreasuryController } from './treasury.controller'; import { TreasuryController } from './treasury.controller';
import { TreasuryAccessGuard } from './treasury-access.guard'; import { TreasuryAccessGuard } from './treasury-access.guard';
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
import { TreasuryService } from './treasury.service'; import { TreasuryService } from './treasury.service';
@Module({ @Module({
imports: [AuthModule, NotificationsModule], imports: [AuthModule, NotificationsModule, ContactsModule],
controllers: [TreasuryController], controllers: [TreasuryController],
providers: [ providers: [
TreasuryService, TreasuryService,
+455 -36
View File
@@ -1,8 +1,10 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
@@ -11,8 +13,14 @@ import type {
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialMovementDto, FinancialMovementDto,
FinancialPartyKind,
TreasuryDashboardDto, TreasuryDashboardDto,
TreasuryPartyBalanceDto,
TreasuryPartySearchDto,
} from '@mento/shared'; } 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 { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -63,6 +71,8 @@ export class TreasuryService {
private readonly receivables: TreasuryReceivablesService, private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService, private readonly storage: StorageQuotaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {} ) {}
async ensureCategories(organizationId: string) { async ensureCategories(organizationId: string) {
@@ -110,27 +120,80 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> { ): Promise<FinancialCategoryDto> {
const name = input.name.trim(); const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
const row = await this.prisma.financialCategory.create({ try {
data: { const row = await this.prisma.financialCategory.create({
organizationId: actor.organizationId, data: {
name, organizationId: actor.organizationId,
kind: input.kind, 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 { if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
id: row.id, const name = input.name == null ? current.name : input.name.trim();
name: row.name, if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
kind: row.kind as FinancialCategoryDto['kind'], try {
isActive: row.isActive, 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( async listAccounts(
actor: Actor, actor: Actor,
range?: { from?: string; to?: string }, range?: { from?: string; to?: string },
): Promise<FinancialAccountDto[]> { ): Promise<FinancialAccountDto[]> {
const scope = await this.accountScope(actor);
const accounts = await this.prisma.financialAccount.findMany({ const accounts = await this.prisma.financialAccount.findMany({
where: { organizationId: actor.organizationId }, where: {
organizationId: actor.organizationId,
...(scope ? { id: { in: scope } } : {}),
},
orderBy: [{ isActive: 'desc' }, { name: 'asc' }], orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
}); });
return Promise.all(accounts.map((account) => this.toAccountDto(account, range))); return Promise.all(accounts.map((account) => this.toAccountDto(account, range)));
@@ -196,19 +259,89 @@ export class TreasuryService {
async updateAccount( async updateAccount(
actor: Actor, actor: Actor,
id: string, 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> { ): Promise<FinancialAccountDto> {
const existing = await this.requireAccount(actor.organizationId, id); const existing = await this.requireAccount(actor.organizationId, id, actor);
const row = await this.prisma.financialAccount.update({ if (input.type && !['bank', 'cash', 'petty'].includes(input.type)) {
where: { id: existing.id }, throw new BadRequestException('نوع حساب نامعتبر است.');
data: { }
...(input.name?.trim() ? { name: input.name.trim() } : {}), const opening = input.openingBalance !== undefined ? dec(input.openingBalance) : null;
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}), if (opening?.isNegative()) {
...(input.responsibleName !== undefined throw new BadRequestException('موجودی اولیه نمی‌تواند منفی باشد.');
? { responsibleName: input.responsibleName?.trim() || null } }
: {}), const openingDate =
...(input.isActive !== undefined ? { isActive: input.isActive } : {}), 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); return this.toAccountDto(row);
} }
@@ -218,7 +351,7 @@ export class TreasuryService {
accountId: string, accountId: string,
range?: { from?: string; to?: string }, range?: { from?: string; to?: string },
): Promise<{ account: FinancialAccountDto; items: FinancialMovementDto[] }> { ): 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 from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
@@ -303,8 +436,8 @@ export class TreasuryService {
if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.'); if (dup) throw new BadRequestException('این انتقال قبلاً ثبت شده است.');
} }
const [source, target] = await Promise.all([ const [source, target] = await Promise.all([
this.requireAccount(actor.organizationId, input.fromAccountId), this.requireAccount(actor.organizationId, input.fromAccountId, actor),
this.requireAccount(actor.organizationId, input.toAccountId), this.requireAccount(actor.organizationId, input.toAccountId, actor),
]); ]);
if (!source.isActive || !target.isActive) { if (!source.isActive || !target.isActive) {
throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.'); throw new BadRequestException('حساب غیرفعال در انتقال قابل استفاده نیست.');
@@ -354,10 +487,12 @@ export class TreasuryService {
kind: 'income' | 'expense', kind: 'income' | 'expense',
q?: string, q?: string,
): Promise<FinancialDocumentDto[]> { ): Promise<FinancialDocumentDto[]> {
const scope = await this.accountScope(actor);
const rows = await this.prisma.financialDocument.findMany({ const rows = await this.prisma.financialDocument.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
kind, kind,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
...(q?.trim() ...(q?.trim()
? { ? {
OR: [ OR: [
@@ -384,7 +519,10 @@ export class TreasuryService {
bookedAt?: string; bookedAt?: string;
dueAt?: string | null; dueAt?: string | null;
categoryId?: string | null; categoryId?: string | null;
partyKind?: FinancialPartyKind | null;
personId?: string | null; personId?: string | null;
companyId?: string | null;
userId?: string | null;
partyName?: string | null; partyName?: string | null;
method?: string | null; method?: string | null;
reference?: string | null; reference?: string | null;
@@ -398,9 +536,13 @@ export class TreasuryService {
const amount = dec(input.amount); const amount = dec(input.amount);
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const party = await this.resolveParty(actor, input);
const settle = input.settleAmount == null || input.settleAmount === '' const settle = input.settleAmount == null || input.settleAmount === ''
? new Prisma.Decimal(0) ? new Prisma.Decimal(0)
: dec(input.settleAmount); : dec(input.settleAmount);
if (party.partyKind === 'other' && !settle.equals(amount)) {
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
}
if (settle.isNegative() || settle.greaterThan(amount)) { if (settle.isNegative() || settle.greaterThan(amount)) {
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
} }
@@ -408,7 +550,7 @@ export class TreasuryService {
throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.'); throw new BadRequestException('برای ثبت وجه قطعی، حساب را انتخاب کنید.');
} }
if (input.accountId) { 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 (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (input.kind === 'expense' && settle.greaterThan(0)) { if (input.kind === 'expense' && settle.greaterThan(0)) {
const balance = await this.accountBalance(account.id); const balance = await this.accountBalance(account.id);
@@ -419,6 +561,13 @@ export class TreasuryService {
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => { 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({ const created = await tx.financialDocument.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -428,12 +577,15 @@ export class TreasuryService {
bookedAt, bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null, dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null, categoryId: input.categoryId || null,
personId: input.personId || null, personId: party.personId,
partyName: input.partyName?.trim() || null, companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
accountId: input.accountId || null, accountId: input.accountId || null,
method: input.method || null, method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null, reference,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
@@ -449,7 +601,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment', kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title, memo: title,
reference: input.reference?.trim() || null, reference,
createdById: actor.userId, createdById: actor.userId,
}, },
}); });
@@ -462,6 +614,221 @@ export class TreasuryService {
return this.toDocumentDto(doc); 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( async settleDocument(
actor: Actor, actor: Actor,
id: string, id: string,
@@ -482,7 +849,7 @@ export class TreasuryService {
if (amount.greaterThan(remaining)) { if (amount.greaterThan(remaining)) {
throw new BadRequestException('مبلغ از مانده سند بیشتر است.'); 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 (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (doc.kind === 'expense') { if (doc.kind === 'expense') {
const balance = await this.accountBalance(account.id); const balance = await this.accountBalance(account.id);
@@ -644,9 +1011,14 @@ export class TreasuryService {
): Promise<TreasuryDashboardDto> { ): Promise<TreasuryDashboardDto> {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const scope = await this.accountScope(actor);
const accountFilter = scope
? { OR: [{ accountId: null }, { accountId: { in: scope } }] }
: {};
const bookedWhere = { const bookedWhere = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
status: { not: 'void' }, status: { not: 'void' },
...accountFilter,
...(from || to ...(from || to
? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } ? { bookedAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}), : {}),
@@ -654,6 +1026,7 @@ export class TreasuryService {
const cashWhere = { const cashWhere = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
voidedAt: null, voidedAt: null,
...(scope ? { accountId: { in: scope } } : {}),
...(from || to ...(from || to
? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } ? { occurredAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } }
: {}), : {}),
@@ -680,12 +1053,13 @@ export class TreasuryService {
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
status: { in: ['open', 'partial'] }, status: { in: ['open', 'partial'] },
...accountFilter,
}, },
include: { movements: true }, include: { movements: true },
}), }),
this.listAccounts(actor), this.listAccounts(actor),
this.prisma.financialDocument.findMany({ this.prisma.financialDocument.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId, ...accountFilter },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, 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({ const row = await this.prisma.financialAccount.findFirst({
where: { id, organizationId }, where: { id, organizationId },
}); });
if (!row) throw new NotFoundException('حساب پیدا نشد.'); if (!row) throw new NotFoundException('حساب پیدا نشد.');
if (actor) {
const scope = await this.accountScope(actor);
if (scope && !scope.includes(row.id)) {
throw new ForbiddenException('به این حساب دسترسی ندارید.');
}
}
return row; return row;
} }
@@ -824,7 +1237,10 @@ export class TreasuryService {
bookedAt: Date; bookedAt: Date;
dueAt: Date | null; dueAt: Date | null;
categoryId: string | null; categoryId: string | null;
partyKind?: string | null;
personId: string | null; personId: string | null;
companyId?: string | null;
userId?: string | null;
partyName: string | null; partyName: string | null;
accountId: string | null; accountId: string | null;
method: string | null; method: string | null;
@@ -851,7 +1267,10 @@ export class TreasuryService {
dueAt: row.dueAt ? row.dueAt.toISOString() : null, dueAt: row.dueAt ? row.dueAt.toISOString() : null,
categoryId: row.categoryId, categoryId: row.categoryId,
categoryName: row.category?.name ?? null, categoryName: row.category?.name ?? null,
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
personId: row.personId, personId: row.personId,
companyId: row.companyId ?? null,
userId: row.userId ?? null,
partyName: row.partyName, partyName: row.partyName,
accountId: row.accountId, accountId: row.accountId,
accountName: row.account?.name ?? null, accountName: row.account?.name ?? null,
+6
View File
@@ -128,4 +128,10 @@ export class UpdateUserDataAccessDto {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
dataAccessOverride?: Record<string, unknown> | null; dataAccessOverride?: Record<string, unknown> | null;
/** null = ارث از نقش */
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
treasuryAccessOverride?: Record<string, unknown> | null;
} }
+28 -6
View File
@@ -14,7 +14,12 @@ import type {
UpdateUserVisibilityRequest, UpdateUserVisibilityRequest,
UserRole, UserRole,
} from '@mento/shared'; } 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 { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope'; import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
@@ -47,6 +52,7 @@ type MembershipRow = {
dataVisibility?: string | null; dataVisibility?: string | null;
dataVisibilityScopes?: unknown; dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown; dataAccessOverride?: unknown;
treasuryAccessOverride?: unknown;
priorArchiveEnabled?: boolean; priorArchiveEnabled?: boolean;
logoutTime?: string | null; logoutTime?: string | null;
blockLoginAfterLogout?: boolean; blockLoginAfterLogout?: boolean;
@@ -370,23 +376,35 @@ export class UsersService {
organizationId: string, organizationId: string,
actorRole: UserRole, actorRole: UserRole,
targetId: string, targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null }, dto: {
dataAccessOverride?: Record<string, unknown> | null;
treasuryAccessOverride?: Record<string, unknown> | null;
},
): Promise<OrgUserDto> { ): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') { if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید'); throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
} }
if (dto.dataAccessOverride === undefined) { if (dto.dataAccessOverride === undefined && dto.treasuryAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است'); throw new BadRequestException('تغییری ارسال نشده است');
} }
const target = await this.memberships.findUserInOrgById(organizationId, targetId); const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') { if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند'); throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
} }
const next = const data: Prisma.MembershipUpdateInput = {};
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride); 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({ await this.prisma.membership.update({
where: { id: target.id }, where: { id: target.id },
data: { dataAccessOverride: next }, data,
}); });
return this.dtoOf(organizationId, targetId); return this.dtoOf(organizationId, targetId);
} }
@@ -606,6 +624,10 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin' row.role === 'owner' || row.role === 'admin'
? {} ? {}
: parseDataAccessPolicy(row.dataAccessOverride), : parseDataAccessPolicy(row.dataAccessOverride),
treasuryAccessOverride:
row.role === 'owner' || row.role === 'admin'
? null
: parseUserTreasuryAccess(row.treasuryAccessOverride),
...(() => { ...(() => {
const effectiveDataAccess = resolveAllSectionAccess({ const effectiveDataAccess = resolveAllSectionAccess({
role: row.role, role: row.role,
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title> <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> <style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */ /* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame { .app-frame {
+25
View File
@@ -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

+1 -1
View File
@@ -15,7 +15,7 @@ import {
Tag as ATag, Tag as ATag,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { ThunderboltOutlined } from '@ant-design/icons-vue'; import { ThunderboltOutlined } from '@/icons/ant-bridge';
const { Paragraph: ATypographyParagraph } = Typography; const { Paragraph: ATypographyParagraph } = Typography;
+56 -54
View File
@@ -8,11 +8,11 @@ import {
InputNumber as AInputNumber, InputNumber as AInputNumber,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Spin as ASpin,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import type { import type {
CustomFieldDefinitionDto, CustomFieldDefinitionDto,
CustomFieldEntity, CustomFieldEntity,
@@ -73,61 +73,63 @@ onMounted(load);
</script> </script>
<template> <template>
<a-spin v-if="loading" /> <CrmSkeleton :loading="loading">
<template v-else-if="fields.length"> <CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
<a-typography-title :level="5" :style="{ marginTop: '16px' }"> <template v-else-if="fields.length">
فیلدهای سفارشی <a-typography-title :level="5" :style="{ marginTop: '16px' }">
</a-typography-title> فیلدهای سفارشی
<a-row :gutter="[16, 0]"> </a-typography-title>
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12"> <a-row :gutter="[16, 0]">
<a-form-item :label="field.label" :required="field.required"> <a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-select <a-form-item :label="field.label" :required="field.required">
v-if="field.fieldType === 'select'" <a-select
:id="`cf-${field.key}`" v-if="field.fieldType === 'select'"
allow-clear :id="`cf-${field.key}`"
:value="String(displayValue(field.key)) || undefined" allow-clear
:options="selectOptions(field)" :value="String(displayValue(field.key)) || undefined"
placeholder="انتخاب کنید" :options="selectOptions(field)"
style="width: 100%" placeholder="انتخاب کنید"
@update:value="setValue(field.key, $event ?? '')" style="width: 100%"
/> @update:value="setValue(field.key, $event ?? '')"
/>
<a-checkbox <a-checkbox
v-else-if="field.fieldType === 'boolean'" v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])" :checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)" @update:checked="setValue(field.key, $event)"
> >
فعال فعال
</a-checkbox> </a-checkbox>
<a-input-number <a-input-number
v-else-if="field.fieldType === 'number'" v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:value=" :value="
props.modelValue[field.key] != null props.modelValue[field.key] != null
? Number(props.modelValue[field.key]) ? Number(props.modelValue[field.key])
: undefined : undefined
" "
style="width: 100%" style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))" @update:value="setValue(field.key, $event == null ? null : Number($event))"
/> />
<JalaliDateInput <JalaliDateInput
v-else-if="field.fieldType === 'date'" v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))" :model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)" @update:model-value="setValue(field.key, $event || null)"
/> />
<a-input <a-input
v-else v-else
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:value="String(displayValue(field.key))" :value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)" @update:value="setValue(field.key, $event)"
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
</CrmSkeleton>
</template> </template>
+68 -104
View File
@@ -1,6 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; 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 { import type {
DataAccessOwnerKind, DataAccessOwnerKind,
DataAccessRule, DataAccessRule,
@@ -15,9 +26,12 @@ import {
PERSON_MOODS, PERSON_MOODS,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
export type IdOption = { id: string; name: string }; export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string }; type Choice = { value: string; label: string };
const { Text: ATypographyText } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: SectionDataAccess; modelValue: SectionDataAccess;
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
</script> </script>
<template> <template>
<div class="dae" :class="{ compact }"> <a-flex vertical :gap="compact ? 8 : 12">
<div class="dae-mode"> <a-radio-group v-model:value="mode" :disabled="disabled">
<label> <a-flex vertical :gap="4">
<input v-model="mode" type="radio" value="all" :disabled="disabled" /> <a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
همهٔ {{ sectionLabel }} <a-radio value="rules">محدود با فیلتر</a-radio>
</label> </a-flex>
<label> </a-radio-group>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'"> <template v-if="mode === 'rules'">
<p class="hint"> <a-typography-text type="secondary">
فقط رکوردهایی دیده می‌شوند که با فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong> <strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت. جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p> </a-typography-text>
<p v-if="hadMultipleRules" class="hint warn"> <a-alert
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود. v-if="hadMultipleRules"
</p> type="warning"
<p class="summary">{{ filterSummary }}</p> show-icon
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود."
/>
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
<fieldset :disabled="disabled"> <a-card size="small" title="مالک / مسئول" :bordered="true">
<legend>مالک / مسئول</legend> <a-flex vertical :gap="8">
<p class="muted">خالی = بدون محدودیت مالک</p> <a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
<label class="chip"> <a-space wrap :size="8">
<input <a-checkbox
type="checkbox" :checked="filter.owners?.includes('self')"
:checked="filter.owners?.includes('self')" :disabled="disabled"
@change="toggleOwner('self')" @change="toggleOwner('self')"
/> >
{{ ownerSelfLabel }} {{ ownerSelfLabel }}
</label> </a-checkbox>
<label class="chip"> <a-checkbox
<input :checked="filter.owners?.includes('team')"
type="checkbox" :disabled="disabled"
:checked="filter.owners?.includes('team')" @change="toggleOwner('team')"
@change="toggleOwner('team')" >
/> تیم‌های عضو
تیم‌های عضو </a-checkbox>
</label> </a-space>
</fieldset> </a-flex>
</a-card>
<label v-for="picker in pickers" :key="picker.legend" class="pick"> <a-form-item
<span>{{ picker.legend }}</span> v-for="picker in pickers"
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p> :key="picker.legend"
:label="picker.legend"
:style="{ marginBottom: 0 }"
>
<a-typography-text v-if="picker.loading" type="secondary">
در حال بارگذاری منبع‌ها…
</a-typography-text>
<a-select <a-select
v-else v-else
:value="pickerValue(picker)" :value="pickerValue(picker)"
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
style="width: 100%" style="width: 100%"
@update:value="setList(picker.key, $event)" @update:value="setList(picker.key, $event)"
/> />
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p> <a-typography-text v-if="picker.hint" type="secondary">
</label> {{ picker.hint }}
</a-typography-text>
</a-form-item>
</template> </template>
<p v-else-if="!hasLimits" class="hint"> <a-typography-text v-else-if="!hasLimits" type="secondary">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند. بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p> </a-typography-text>
</div> </a-flex>
</template> </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>
+167 -89
View File
@@ -1,31 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; 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 { import type {
DataAccessPolicy, DataAccessPolicy,
DataAccessSection, DataAccessSection,
FinancialAccountDto,
PersonImportSourceStatDto, PersonImportSourceStatDto,
RolePermissionKey, RolePermissionKey,
SectionDataAccess, SectionDataAccess,
UserTreasuryAccess,
} from '@mento/shared'; } from '@mento/shared';
import { import {
DATA_ACCESS_SECTION_LABELS, DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS, DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
emptySectionDataAccess, emptySectionDataAccess,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups'; import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue'; import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ const props = defineProps<{
modelValue: DataAccessPolicy; modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */ /** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[]; enabledPermissions?: RolePermissionKey[];
disabled?: boolean; disabled?: boolean;
/** تب‌های عمق داده قفل شوند؛ تب مدیریت مالی جدا می‌ماند */
dataLocked?: boolean;
showTreasury?: boolean;
treasuryAccess?: UserTreasuryAccess | null;
compact?: boolean; compact?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy]; 'update:modelValue': [DataAccessPolicy];
'update:treasuryAccess': [UserTreasuryAccess | null];
}>(); }>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */ /** بخش‌هایی که به 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( watch(
visibleSections, visibleSections,
(list) => { (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 }, { immediate: true },
); );
@@ -89,6 +110,52 @@ const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]); const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]); const projectStages = ref<IdOption[]>([]);
const routineCategories = 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() { async function loadOptions() {
await loadCustomerGroups().catch(() => undefined); await loadCustomerGroups().catch(() => undefined);
@@ -176,96 +243,107 @@ async function loadOptions() {
onMounted(() => { onMounted(() => {
void loadOptions(); void loadOptions();
}); void loadTreasuryAccounts();
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 [];
}); });
</script> </script>
<template> <template>
<div class="dap"> <div>
<p v-if="!visibleSections.length" class="muted"> <a-typography-text v-if="!visibleSections.length" type="secondary">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید. برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p> </a-typography-text>
<template v-else> <a-tabs v-else v-model:activeKey="active">
<div class="tabs" role="tablist"> <a-tab-pane
<button v-for="s in visibleSections"
v-for="s in visibleSections" :key="s"
:key="s" :tab="DATA_ACCESS_SECTION_LABELS[s]"
type="button" >
class="tab" <DataAccessEditor
:class="{ on: active === s }" :section="s"
role="tab" :model-value="sectionValue(s)"
:aria-selected="active === s" :groups="customerGroups"
@click="active = s" :stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
> :pipelines="pipelines"
{{ DATA_ACCESS_SECTION_LABELS[s] }} :boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
</button> :activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
</div> :import-sources="importSources"
<DataAccessEditor :import-sources-ready="importSourcesReady"
:key="active" :import-sources-failed="importSourcesFailed"
:section="active" :disabled="disabled || dataLocked"
:model-value="sectionValue(active)" :compact="compact"
:groups="customerGroups" @update:model-value="setSection(s, $event)"
:stages="stagesForActive" />
:pipelines="pipelines" </a-tab-pane>
:boards="boardsForActive" <a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
:activity-types="typesForActive" <a-space direction="vertical">
:import-sources="importSources" <a-typography-text type="secondary">
:import-sources-ready="importSourcesReady" این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
:import-sources-failed="importSourcesFailed" در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
:disabled="disabled" اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
:compact="compact" </a-typography-text>
@update:model-value="setSection(active, $event)" <a-radio-group
/> :value="treasuryAccess == null ? 'role' : 'custom'"
</template> :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> </div>
</template> </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>
+5 -3
View File
@@ -10,11 +10,11 @@ import {
Input as AInput, Input as AInput,
List as AList, List as AList,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Tag as ATag, Tag as ATag,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact; const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item; const AListItem = AList.Item;
@@ -259,7 +259,8 @@ onMounted(load);
:style="{ marginTop: '12px' }" :style="{ marginTop: '12px' }"
/> />
<a-spin :spinning="loading"> <CrmSkeleton :loading="loading">
<CrmSkeletonList :rows="5">
<a-list <a-list
v-if="items.length" v-if="items.length"
item-layout="horizontal" item-layout="horizontal"
@@ -304,6 +305,7 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید." description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }" :style="{ marginTop: '16px' }"
/> />
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
</a-card> </a-card>
</template> </template>
@@ -8,7 +8,7 @@ import {
Space as ASpace, Space as ASpace,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { PrinterOutlined } from '@ant-design/icons-vue'; import { PrinterOutlined } from '@/icons/ant-bridge';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money'; import { formatMoney, currencySuffix } from '@/lib/money';
+1 -1
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
LeftOutlined, LeftOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
addJalaliMonths, addJalaliMonths,
dateToJalali, dateToJalali,
+5 -3
View File
@@ -9,10 +9,10 @@ import {
List as AList, List as AList,
Modal, Modal,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali'; 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-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 v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده. هنوز موردی در حافظه ثبت نشده.
</a-typography-text> </a-typography-text>
@@ -201,6 +202,7 @@ onMounted(load);
</a-list-item> </a-list-item>
</template> </template>
</a-list> </a-list>
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
</a-card> </a-card>
</template> </template>
+296 -139
View File
@@ -3,35 +3,59 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles'; import { USER_ROLE_LABELS } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; import type { OrganizationMembershipDto } from '@mento/shared';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
defineProps<{ const props = withDefaults(
compact?: boolean; defineProps<{
compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]); const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false); const open = ref(false);
const createOpen = ref(false);
const loading = ref(false); const loading = ref(false);
const creating = ref(false);
const error = ref(''); const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null); const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null); const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({}); const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0; let ignoreDocClickUntil = 0;
const currentName = computed( 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 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) { function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { 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() { async function toggle() {
if (!canSwitch.value) return; if (!menuEnabled.value) return;
error.value = ''; error.value = '';
if (open.value) { if (open.value) {
open.value = false; closeMenu();
return; return;
} }
ignoreDocClickUntil = Date.now() + 400; ignoreDocClickUntil = Date.now() + 400;
open.value = true; open.value = true;
emit('open');
await nextTick(); await nextTick();
placeMenu(); placeMenu();
await loadOrgs(); await loadOrgs();
await nextTick();
placeMenu();
} }
function placeMenu() { function placeMenu() {
const btn = rootEl.value?.querySelector('button'); const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return; if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect(); 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 = { menuStyle.value = {
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`, ...beside,
right: `${Math.round(window.innerWidth - box.right)}px`, top: 'auto',
minWidth: `${Math.round(Math.max(box.width, 200))}px`, bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
}; };
} }
async function choose(org: OrganizationMembershipDto) { async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) { if (org.id === auth.user?.organizationId) {
open.value = false; closeMenu();
return; return;
} }
loading.value = true; 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) { function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return; if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target; const target = event.target;
if (!(target instanceof Node)) return; if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return; if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false; closeMenu();
} }
defineExpose({ close: closeMenu });
onMounted(() => { onMounted(() => {
void loadOrgs(); void loadOrgs();
document.addEventListener('click', onDocClick); document.addEventListener('click', onDocClick);
@@ -137,27 +163,46 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div ref="rootEl" class="org-switch"> <div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
<button <button
class="org-btn" class="org-btn"
:class="{ compact, static: !canSwitch }" :class="{
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
type="button" type="button"
:title="currentName" :title="currentName"
:aria-disabled="!canSwitch" :aria-expanded="open"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
@click.stop="toggle" @click.stop="toggle"
> >
<UiIcon name="building" :size="18" /> <template v-if="isWorkspace">
<span>{{ currentName }}</span> <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> </button>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="open && canSwitch" v-if="open && menuEnabled"
ref="menuEl" ref="menuEl"
class="menu" class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle" :style="menuStyle"
role="listbox" role="listbox"
aria-label="محیط‌های کاری"
> >
<p class="hint">ورود به محیط</p> <p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
<button <button
v-for="org in orgs" v-for="org in orgs"
:key="org.id" :key="org.id"
@@ -165,41 +210,25 @@ onUnmounted(() => {
class="org-item" class="org-item"
:class="{ on: org.id === auth.user?.organizationId }" :class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading" :disabled="loading"
role="option"
:aria-selected="org.id === auth.user?.organizationId"
@click="choose(org)" @click="choose(org)"
> >
<strong>{{ org.name }}</strong> <span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</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>
<button type="button" class="create" :disabled="loading" @click="openCreate"> <p v-if="error" class="err">{{ error }}</p>
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div> </div>
</Teleport> </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> </div>
</template> </template>
@@ -234,7 +263,7 @@ onUnmounted(() => {
margin-top: 0.05rem; margin-top: 0.05rem;
} }
.org-btn span { .org-btn > span {
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -253,7 +282,7 @@ onUnmounted(() => {
color: #94a3b8; color: #94a3b8;
} }
.org-btn.compact { .org-btn.compact:not(.org-ws) {
display: grid; display: grid;
place-items: center; place-items: center;
width: 100%; width: 100%;
@@ -262,18 +291,113 @@ onUnmounted(() => {
border-radius: var(--radius); border-radius: var(--radius);
} }
.org-btn.compact span { .org-btn.compact:not(.org-ws) > span {
display: none; display: none;
} }
.org-btn.compact:hover { .org-btn.compact:not(.org-ws):hover {
background: #f8fafc; background: #f8fafc;
} }
.org-btn.compact.static:hover { .org-btn.compact.static:not(.org-ws):hover {
background: transparent; 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 { .menu {
position: fixed; position: fixed;
z-index: 90; z-index: 90;
@@ -286,11 +410,21 @@ onUnmounted(() => {
gap: 0.2rem; 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 { .hint {
margin: 0; margin: 0;
padding: 0.25rem 0.45rem; padding: 0.35rem 0.65rem 0.45rem;
font-size: 0.72rem; font-size: 0.7rem;
color: var(--muted); font-weight: 700;
letter-spacing: 0.02em;
color: #94a3b8;
} }
.org-item, .org-item,
@@ -305,29 +439,89 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
} }
.org-item strong { .menu.org-ws .org-item {
font-size: 0.82rem; 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 { .org-item-mark {
font-size: 0.72rem; width: 36px;
color: var(--muted); 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, .org-item:hover,
.create: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 { .org-item.on {
background: var(--accent-soft); background: #f3f4f6;
} }
.create { .menu.org-ws .org-item.on {
margin-top: 0.15rem; background: #f3f4f6;
color: var(--accent); }
font-weight: 700;
font-size: 0.82rem; .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 { .err {
@@ -335,41 +529,4 @@ onUnmounted(() => {
color: #b91c1c; color: #b91c1c;
font-size: 0.75rem; 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> </style>
@@ -18,7 +18,7 @@ import {
Upload as AUpload, Upload as AUpload,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { UploadProps } 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 { import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
+339 -65
View File
@@ -185,6 +185,13 @@ const fabStyle = computed(() => ({
bottom: 'auto', 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 panelStyle = computed(() => {
const gap = 12; const gap = 12;
const edge = 16; const edge = 16;
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge); left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left); left = Math.max(edge, left);
const spaceAbove = fabPos.value.top - edge; const placeAbove = panelPlacement.value === 'above';
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge; const maxHeight = Math.max(
const placeAbove = spaceAbove >= spaceBelow; 0,
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap); (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) { if (placeAbove) {
return { return {
left: `${left}px`, ...shared,
right: 'auto',
bottom: `${viewport.value.h - fabPos.value.top + gap}px`, bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto', top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
}; };
} }
return { return {
left: `${left}px`, ...shared,
right: 'auto',
top: `${fabPos.value.top + FAB_SIZE + gap}px`, top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto', 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) => { watch(hidden, (value) => {
if (value) close(); if (value) close();
}); });
@@ -264,46 +272,51 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
}); });
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl"> <div v-if="!hidden" class="qc" dir="rtl">
<button <Transition name="qc-overlay">
v-if="isQuickCreateOpen" <button
class="overlay" v-if="isQuickCreateOpen"
type="button" class="overlay"
aria-label="بستن ایجاد سریع" type="button"
@click="close" aria-label="بستن ایجاد سریع"
/> @click="close"
/>
</Transition>
<section <Transition name="qc-panel">
v-if="isQuickCreateOpen" <section
class="panel" v-if="isQuickCreateOpen"
aria-labelledby="qc-title" class="panel"
:style="panelStyle" :data-place="panelPlacement"
> aria-labelledby="qc-title"
<h2 id="qc-title">ایجاد سریع</h2> :style="panelStyle"
<div class="items"> >
<button <h2 id="qc-title">ایجاد سریع</h2>
v-for="item in items" <div class="items">
:key="item.key" <button
class="item" v-for="(item, index) in items"
type="button" :key="item.key"
@click="go(item.to)" class="item"
> type="button"
<span class="item-ico" :class="item.tone"> :style="{ '--qc-i': index }"
<UiIcon :name="item.icon" :size="18" /> @click="go(item.to)"
</span> >
<span class="copy"> <span class="item-ico" :class="item.tone">
<strong>{{ item.title }}</strong> <UiIcon :name="item.icon" :size="18" />
<small>{{ item.desc }}</small> </span>
</span> <span class="copy">
</button> <strong>{{ item.title }}</strong>
</div> <small>{{ item.desc }}</small>
</section> </span>
</button>
</div>
</section>
</Transition>
<button <button
v-if="ready" v-if="ready"
@@ -319,6 +332,8 @@ onBeforeUnmount(() => {
@pointerup="onFabPointerUp" @pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp" @pointercancel="onFabPointerUp"
> >
<span class="fab-pulse" aria-hidden="true" />
<span class="fab-shine" aria-hidden="true" />
<span class="fab-face" aria-hidden="true"> <span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" /> <UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span> </span>
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
z-index: 90; z-index: 90;
border: 0; border: 0;
padding: 0; padding: 0;
background: rgba(0, 0, 0, 0.68); background: rgba(15, 23, 42, 0.42);
cursor: pointer; cursor: pointer;
} }
.panel { .panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 92; z-index: 92;
width: 300px; width: 300px;
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: 14px;
padding: 12px 12px 10px; 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; 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 { .panel h2 {
@@ -369,12 +465,20 @@ onBeforeUnmount(() => {
.items { .items {
min-height: 0; min-height: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding-left: 2px; padding-left: 2px;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none;
}
.items::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.item { .item {
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
} }
.fab { .fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 93; z-index: 93;
box-sizing: border-box; box-sizing: border-box;
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
margin: 0; margin: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: visible;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%); background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
background-size: 140% 140%;
color: #fff; color: #fff;
display: grid; display: grid;
place-items: center; place-items: center;
line-height: 0; line-height: 0;
direction: ltr; direction: ltr;
isolation: isolate;
box-shadow: box-shadow:
0 4px 14px rgba(13, 148, 136, 0.35), 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; cursor: grab;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
transition: transition:
filter 0.15s ease, filter 0.35s var(--fab-ease),
box-shadow 0.15s ease, box-shadow 0.35s var(--fab-ease),
transform 0.12s 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 { .fab-face {
position: relative;
z-index: 2;
display: grid; display: grid;
place-items: center; place-items: center;
place-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0;
padding: 0;
pointer-events: none; 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; display: block;
width: 20px; width: 20px;
height: 20px; height: 20px;
max-width: 20px; max-width: 20px;
max-height: 20px; max-height: 20px;
margin: 0;
stroke-width: 1.7; 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 { .fab:hover {
filter: brightness(1.06); filter: brightness(1.07) saturate(1.05);
transform: translateY(-1px) scale(1.04);
box-shadow: box-shadow:
0 6px 18px rgba(13, 148, 136, 0.42), 0 10px 24px rgba(13, 148, 136, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.25); 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 { .fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%); 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 { .fab.dragging {
cursor: grabbing; cursor: grabbing;
transform: scale(1.05); transform: scale(1.08);
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45); 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> </style>
+144
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue'; import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Checkbox as ACheckbox, Checkbox as ACheckbox,
+8 -5
View File
@@ -264,10 +264,12 @@ defineExpose({ refresh: load });
.layer { .layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 80; z-index: 120;
display: grid; display: flex;
place-items: center; align-items: center;
justify-content: center;
padding: 1rem; padding: 1rem;
overflow: auto;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -279,8 +281,9 @@ defineExpose({ refresh: load });
.modal { .modal {
position: relative; position: relative;
width: min(420px, 100%); width: min(420px, calc(100vw - 2rem));
max-height: min(90vh, 640px); max-height: calc(100vh - 2rem);
margin: auto;
overflow: auto; overflow: auto;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
+1 -1
View File
@@ -7,7 +7,7 @@ import {
Popover as APopover, Popover as APopover,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { SettingOutlined } from '@ant-design/icons-vue'; import { SettingOutlined } from '@/icons/ant-bridge';
import { useTableColumns } from '@/lib/tableColumns'; import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
+292 -364
View File
@@ -11,6 +11,7 @@ import type {
TeamDto, TeamDto,
TeamMemberRole, TeamMemberRole,
UpdatePriorArchiveCutoffRequest, UpdatePriorArchiveCutoffRequest,
UserTreasuryAccess,
} from '@mento/shared'; } from '@mento/shared';
import { import {
DATA_ACCESS_SECTION_LABELS, DATA_ACCESS_SECTION_LABELS,
@@ -18,9 +19,36 @@ import {
emptySectionDataAccess, emptySectionDataAccess,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali'; 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<{ const props = defineProps<{
users: OrgUserDto[]; users: OrgUserDto[];
@@ -36,7 +64,7 @@ const showPriorArchive = computed(
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false)); const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled)); const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref(''); const priorSearch = ref('');
const priorPick = ref(''); const priorPick = ref<string | undefined>(undefined);
const priorSavingId = ref(''); const priorSavingId = ref('');
const cutoffLocal = ref(''); const cutoffLocal = ref('');
const cutoffSaving = ref(false); const cutoffSaving = ref(false);
@@ -47,10 +75,16 @@ const availablePriorUsers = computed(() => {
return priorUsers.value.filter((u) => { return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false; if (u.priorArchiveEnabled) return false;
if (!q) return true; 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<{ const emit = defineEmits<{
refreshUsers: []; refreshUsers: [];
}>(); }>();
@@ -73,9 +107,14 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'), 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) { async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId; priorSavingId.value = userId;
priorPick.value = ''; priorPick.value = undefined;
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
@@ -113,7 +152,10 @@ async function loadPriorArchiveCutoff() {
applyCutoffFromOrg(data); applyCutoffFromOrg(data);
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.') ? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخ بایگانی ناموفق بود.',
)
: 'بارگذاری تاریخ بایگانی ناموفق بود.'; : 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally { } finally {
cutoffLoading.value = false; cutoffLoading.value = false;
@@ -135,7 +177,10 @@ async function onCutoffChange(value: string) {
await auth.hydrate(); await auth.hydrate();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.') ? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تاریخ بایگانی ناموفق بود.',
)
: 'ذخیره تاریخ بایگانی ناموفق بود.'; : 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff(); await loadPriorArchiveCutoff();
} finally { } finally {
@@ -143,9 +188,10 @@ async function onCutoffChange(value: string) {
} }
} }
function pickPriorUser(userId: string) { function pickPriorUser(userId: unknown) {
if (!userId) return; const id = userId == null ? '' : String(userId);
void setPriorArchive(userId, true); if (!id) return;
void setPriorArchive(id, true);
} }
async function load() { 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; 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() { 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 expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({}); const draftAccess = ref<DataAccessPolicy>({});
const draftTreasury = ref<UserTreasuryAccess | null>(null);
const accessMode = ref<'inherit' | 'custom'>('inherit'); const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false); const accessSaving = ref(false);
@@ -323,6 +384,7 @@ function openAccessEditor(u: OrgUserDto) {
draftAccess.value = hasOverride draftAccess.value = hasOverride
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) } ? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess); : clonePolicy(u.effectiveDataAccess);
draftTreasury.value = u.treasuryAccessOverride ?? null;
expandedAccess.value = u.id; expandedAccess.value = u.id;
} }
@@ -333,8 +395,8 @@ async function saveUserAccess(userId: string) {
try { try {
const payload = const payload =
accessMode.value === 'inherit' accessMode.value === 'inherit'
? { dataAccessOverride: null } ? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
: { dataAccessOverride: draftAccess.value }; : { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
await api.patch(`/users/${userId}/data-access`, payload); await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.'; ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null; expandedAccess.value = null;
@@ -358,360 +420,226 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="teams-panel"> <a-flex vertical :gap="12">
<p v-if="error" class="error">{{ error }}</p> <a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<p v-if="ok" class="ok">{{ ok }}</p> <a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<section v-if="canEdit" class="card"> <a-card :bordered="false">
<h3>تیم جدید</h3> <a-form v-if="canEdit" layout="vertical" @finish="createTeam">
<div class="row"> <a-divider orientation="right">تیم جدید</a-divider>
<UiField label="نام تیم"> <a-row :gutter="[16, 0]" align="bottom">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" /> <a-col :xs="24" :md="8">
</UiField> <a-form-item label="نام تیم">
<UiField label="توضیح"> <a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
<UiInput v-model="newDesc" placeholder="اختیاری" /> </a-form-item>
</UiField> </a-col>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton> <a-col :xs="24" :md="10">
</div> <a-form-item label="توضیح">
</section> <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"> <a-divider orientation="right">تیم‌ها</a-divider>
<h3>تیم‌ها</h3> <CrmSkeleton :loading="loading">
<UiEmptyState <CrmSkeletonCard :rows="6">
v-if="!loading && !teams.length" <a-empty
title="تیمی تعریف نشده" v-if="!loading && !teams.length"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند." 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"
/> />
</header> <a-list v-else item-layout="vertical" :data-source="teams">
<p class="hint"> <template #renderItem="{ item: t }">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ <a-list-item>
رکوردها را مطابق سطح دسترسی خودشان می‌بینند. <template v-if="canEdit" #actions>
</p> <a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
<div class="prior-picker"> <a-button type="link" @click="startEdit(t)">اعضا</a-button>
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" /> <a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
<select </template>
v-model="priorPick" <a-list-item-meta>
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)" <template #title>
@change="pickPriorUser(($event.target as HTMLSelectElement).value)" <a-space wrap>
> <a-typography-text strong>{{ t.name }}</a-typography-text>
<option value=""> <a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }} </a-space>
</option> </template>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id"> <template #description>
{{ u.fullName }} <a-typography-text type="secondary">
</option> {{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
</select> </a-typography-text>
</div> </template>
<div v-if="selectedPriorUsers.length" class="prior-chips"> </a-list-item-meta>
<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>
<section class="card"> <a-form
<h3>محدوده داده</h3> v-if="editingMetaId === t.id"
<p class="hint"> layout="vertical"
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر style="width: 100%"
جدا بگذارید. @finish="saveMeta"
</p> >
<UiEmptyState <a-row :gutter="[16, 0]">
v-if="!editableUsers.length" <a-col :xs="24" :md="12">
title="کاربر قابل تنظیم نیست" <a-form-item label="نام تیم">
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند." <a-input v-model:value="editName" style="width: 100%" />
/> </a-form-item>
<div v-else class="vis-table"> </a-col>
<div v-for="u in editableUsers" :key="u.id" class="vis-user"> <a-col :xs="24" :md="12">
<div class="vis-row"> <a-form-item label="توضیح">
<span> <a-input v-model:value="editDesc" style="width: 100%" />
{{ u.fullName }} </a-form-item>
<small class="vis-badge">{{ accessSummary(u) }}</small> </a-col>
</span> </a-row>
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)"> <a-space>
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }} <a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
</UiButton> <a-button @click="editingMetaId = null">انصراف</a-button>
</div> </a-space>
<div v-if="expandedAccess === u.id" class="vis-scopes"> </a-form>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" /> <a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
مثل نقش (بدون override) <a-typography-text type="secondary">
</label> اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
<label class="chip-row"> </a-typography-text>
<input v-model="accessMode" type="radio" value="custom" /> <a-row :gutter="[12, 8]">
سفارشی برای این کاربر <a-col
</label> v-for="u in users.filter((x) => x.isActive !== false)"
<DataAccessPolicyPanel :key="u.id"
v-if="accessMode === 'custom'" :xs="24"
v-model="draftAccess" :sm="12"
compact :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"> <a-list v-else item-layout="vertical" :data-source="editableUsers">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton> <template #renderItem="{ item: u }">
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton> <a-list-item>
</div> <template v-if="canEdit" #actions>
</div> <a-button type="link" @click="openAccessEditor(u)">
</div> {{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
</div> </a-button>
</section> </template>
</div> <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> </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>
+7 -346
View File
@@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{ defineProps<{
name: name:
| 'home' | 'home'
@@ -82,352 +88,7 @@ defineProps<{
</script> </script>
<template> <template>
<svg <VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
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>
</template> </template>
<style scoped> <style scoped>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-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'; import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
+76
View File
@@ -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 { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types'; import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; 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 PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import { import {
ACTIVITY_BOARD_COPY, ACTIVITY_BOARD_COPY,
initialsFromTitle, initialsFromTitle,
@@ -121,6 +122,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
assigneeNames, assigneeNames,
ownerId: item.assignee?.id ?? null, ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id), 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) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId]!;
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; 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 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 = { const prevFrom = from.items;
fromItems: from.items, const prevTo = to.items;
fromTotal: from.total, const prevFromTotal = from.total;
toItems: to.items, const prevToTotal = to.total;
toTotal: to.total,
};
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId }; const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId); const nextTo = [...without];
from.total = Math.max(0, from.total - 1); nextTo.splice(insertAt, 0, optimistic);
const nextTo = [...to.items]; if (same) {
const at = index == null || index < 0 || index > nextTo.length ? 0 : index; from.items = nextTo;
nextTo.splice(at, 0, optimistic); } else {
to.items = nextTo; from.items = from.items.filter((i) => i.id !== activityId);
to.total += 1; from.total = Math.max(0, from.total - 1);
to.items = nextTo;
to.total += 1;
}
moving.value = true; moving.value = true;
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, 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'); emit('changed');
} catch (e) { } catch (e) {
from.items = snapshot.fromItems; from.items = prevFrom;
from.total = snapshot.fromTotal; from.total = prevFromTotal;
to.items = snapshot.toItems; if (!same) {
to.total = snapshot.toTotal; to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
moving.value = false; moving.value = false;
@@ -426,36 +442,47 @@ function openActivity(id: string) {
flexDirection: 'column', flexDirection: 'column',
}" }"
> >
<a-spin <CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
v-if="initialLoading && !Object.keys(columns).length" <a-flex
tip="در حال بارگذاری ستون‌ها…" v-if="initialLoading && !Object.keys(columns).length"
/> :gap="12"
<PipelineBoard :style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
v-else >
:stages="boardStages" <div
:dragging-id="draggingId" v-for="i in 4"
:drop-stage-id="dropStageId" :key="`sk-col-${i}`"
:can-manage="canManage" :style="{ width: '280px', flex: '0 0 280px' }"
:copy="ACTIVITY_BOARD_COPY" >
:select-mode="selectMode" <CrmSkeletonCard :rows="6" :min-height="360" />
:selected-ids="selectedIds" </div>
@drag-start="onDragStart" </a-flex>
@drag-end="onDragEnd" <PipelineBoard
@drag-over="onDragOver" v-else
@drop="onDrop" :stages="boardStages"
@add-deal="emit('add', $event)" :dragging-id="draggingId"
@open-deal="openActivity" :drop-stage-id="dropStageId"
@menu-deal="onMenuActivity" :can-manage="canManage"
@toggle-select="emit('toggleSelect', $event)" :copy="ACTIVITY_BOARD_COPY"
@bulk-delete="emit('bulkDelete')" :select-mode="selectMode"
@rename-stage="onRenameStage" :selected-ids="selectedIds"
@delete-stage="onDeleteStage" @drag-start="onDragStart"
@move-stage="onMoveStage" @drag-end="onDragEnd"
@column-drag-start="(id: string) => (draggingColumnId = id)" @drag-over="onDragOver"
@column-drag-end="draggingColumnId = null; dropStageId = null" @drop="onDrop"
@add-stage="onAddStage" @add-deal="emit('add', $event)"
@load-more="loadMore" @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"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -36,7 +36,7 @@ import {
PlusOutlined, PlusOutlined,
UpOutlined, UpOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
@@ -16,7 +16,7 @@ import {
ArrowDownOutlined, ArrowDownOutlined,
ArrowUpOutlined, ArrowUpOutlined,
DeleteOutlined, DeleteOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
@@ -41,7 +41,7 @@ import {
TeamOutlined, TeamOutlined,
ThunderboltOutlined, ThunderboltOutlined,
ContainerOutlined, ContainerOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 AutomationNode from '@/components/automations/AutomationNode.vue';
import { import {
AUTOMATION_ACTION_LABELS, AUTOMATION_ACTION_LABELS,
@@ -29,13 +29,15 @@ import {
ExpandOutlined, ExpandOutlined,
FilterOutlined, FilterOutlined,
FlagOutlined, FlagOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
GiftOutlined, GiftOutlined,
PlusOutlined, PlusOutlined,
SelectOutlined, SelectOutlined,
ThunderboltOutlined, ThunderboltOutlined,
ZoomInOutlined, ZoomInOutlined,
ZoomOutOutlined, ZoomOutOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
export type AutomationAddPayload = export type AutomationAddPayload =
| { kind: 'action'; action: AutomationAction } | { kind: 'action'; action: AutomationAction }
@@ -56,8 +58,15 @@ const props = withDefaults(
form?: LeadFormDto | null; form?: LeadFormDto | null;
users?: OrgUserDto[]; users?: OrgUserDto[];
teams?: TeamDto[]; teams?: TeamDto[];
fullscreen?: boolean;
}>(), }>(),
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] }, {
chainMode: 'stop',
form: null,
users: () => [],
teams: () => [],
fullscreen: false,
},
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -66,6 +75,7 @@ const emit = defineEmits<{
remove: [id: string]; remove: [id: string];
removeRange: [start: number, end: number]; removeRange: [start: number, end: number];
addBranch: []; addBranch: [];
'update:fullscreen': [value: boolean];
}>(); }>();
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null); const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
@@ -169,6 +179,21 @@ function onViewportClick() {
addAt.value = null; 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 layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
const steps = props.steps; const steps = props.steps;
const { prefixEnd, branches } = splitAutomationBranches(steps); const { prefixEnd, branches } = splitAutomationBranches(steps);
@@ -395,42 +420,44 @@ function removeBranch(lane: BranchLane) {
> >
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" /> <PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
</button> </button>
<div <Transition name="auto-picker">
v-if="addAt?.key === item.key" <div
class="picker" v-if="addAt?.key === item.key"
@click.stop class="picker"
@pointerdown.stop @click.stop
> @pointerdown.stop
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
> >
<span class="pick-ico"><FilterOutlined /></span> <div class="picker-head">افزودن گره</div>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button <button
v-for="action in AUTOMATION_ACTIONS" v-if="addAt.allow !== 'action'"
:key="action"
type="button" type="button"
class="pick" class="pick condition"
:class="action" @click="pickCondition"
@click="pickAction(action)"
> >
<span class="pick-ico"><component :is="actionIcon(action)" /></span> <span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy"> <span class="pick-copy">
<strong>{{ actionShort(action) }}</strong> <strong>شرط</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small> <small>اگر … آنگاه</small>
</span> </span>
</button> </button>
</template> <template v-if="addAt.allow !== 'condition'">
</div> <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> </div>
<AutomationNode <AutomationNode
v-else v-else
@@ -474,42 +501,44 @@ function removeBranch(lane: BranchLane) {
> >
<PlusOutlined :style="{ fontSize: '14px' }" /> <PlusOutlined :style="{ fontSize: '14px' }" />
</button> </button>
<div <Transition name="auto-picker">
v-if="addAt?.key === item.key" <div
class="picker" v-if="addAt?.key === item.key"
@click.stop class="picker"
@pointerdown.stop @click.stop
> @pointerdown.stop
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
> >
<span class="pick-ico"><FilterOutlined /></span> <div class="picker-head">افزودن گره</div>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<button <button
v-for="action in AUTOMATION_ACTIONS" v-if="addAt.allow !== 'action'"
:key="action"
type="button" type="button"
class="pick" class="pick condition"
:class="action" @click="pickCondition"
@click="pickAction(action)"
> >
<span class="pick-ico"><component :is="actionIcon(action)" /></span> <span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy"> <span class="pick-copy">
<strong>{{ actionShort(action) }}</strong> <strong>شرط</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small> <small>اگر … آنگاه</small>
</span> </span>
</button> </button>
</template> <template v-if="addAt.allow !== 'condition'">
</div> <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> </div>
<AutomationNode <AutomationNode
v-else v-else
@@ -582,6 +611,19 @@ function removeBranch(lane: BranchLane) {
<template #icon><ExpandOutlined /></template> <template #icon><ExpandOutlined /></template>
</a-button> </a-button>
</a-tooltip> </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> </a-space>
</div> </div>
</div> </div>
@@ -892,6 +934,28 @@ strong {
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); 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 { .picker-head {
padding: 6px 8px 8px; padding: 6px 8px 8px;
font-size: 11px; font-size: 11px;
@@ -6,7 +6,7 @@ import {
FilterOutlined, FilterOutlined,
FlagOutlined, FlagOutlined,
GiftOutlined, GiftOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { isAutomationCondition, type AutomationStep } from '@mento/shared'; import { isAutomationCondition, type AutomationStep } from '@mento/shared';
const props = defineProps<{ const props = defineProps<{
@@ -2,11 +2,13 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { RobotOutlined } from '@ant-design/icons-vue';
import { import {
Alert as AAlert, Alert as AAlert,
Button as AButton, Button as AButton,
Card as ACard,
Col as ACol, Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm, Form as AForm,
Input as AInput, Input as AInput,
Modal as AModal, Modal as AModal,
@@ -15,12 +17,15 @@ import {
Space as ASpace, Space as ASpace,
Tag as ATag, Tag as ATag,
Typography, Typography,
theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali'; import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared'; import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
import { LinkOutlined } from '@/icons/ant-bridge';
const props = defineProps<{ const props = defineProps<{
askQuery: string; askQuery: string;
@@ -38,11 +43,41 @@ const emit = defineEmits<{
notice: [value: { error: string; done: string }]; 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 AFormItem = AForm.Item;
const ATextarea = AInput.TextArea; 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 = [ const shortcuts = [
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' }, { label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
{ label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' }, { label: 'عقب‌افتاده‌ها', prompt: 'فعالیت‌های عقب‌افتاده چیست؟' },
@@ -296,33 +331,40 @@ watch(
</script> </script>
<template> <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"> <section id="home-ai-assistant" class="home-ai">
<div class="flex items-start justify-between gap-3"> <div class="home-ai__pattern" aria-hidden="true" />
<div class="flex items-center gap-3 min-w-0"> <div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0"> <div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
<RobotOutlined />
</span> <header class="home-ai__head">
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3> <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> </div>
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag> <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-if="answer">{{ answer }}</template>
<template v-else-if="error">{{ error }}</template> <template v-else-if="error">{{ error }}</template>
<template v-else> <template v-else>
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong> <strong>پیشنهاد هوشمند امروز:</strong>
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید. از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
</template> </template>
</div> </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 <a-input
:value="askQuery" :value="askQuery"
:placeholder="brand.askPlaceholderFa" :placeholder="brand.askPlaceholderFa"
:bordered="false" :bordered="false"
:disabled="asking || voiceBusy || confirming" :disabled="asking || voiceBusy || confirming"
class="flex-1" class="home-ai__input"
@update:value="emit('update:askQuery', $event)" @update:value="emit('update:askQuery', $event)"
@press-enter="onTypedSubmit" @press-enter="onTypedSubmit"
/> />
@@ -345,14 +387,14 @@ watch(
</a-button> </a-button>
</div> </div>
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2"> <div class="home-ai__chips">
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span> <span class="home-ai__chips-label">پیشنهادهای سریع</span>
<div class="flex flex-wrap gap-1.5"> <div class="home-ai__chip-list">
<button <button
v-for="item in shortcuts" v-for="item in shortcuts"
:key="item.label" :key="item.label"
type="button" 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" :disabled="asking"
@click="pick(item.prompt)" @click="pick(item.prompt)"
> >
@@ -361,10 +403,12 @@ watch(
</div> </div>
</div> </div>
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" /> <div v-if="voiceBusy || confirming || voiceDone || voiceError" class="home-ai__status">
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" /> <a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" /> <a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" /> <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> </section>
<a-modal <a-modal
@@ -493,22 +537,365 @@ watch(
</template> </template>
</a-modal> </a-modal>
<a-modal v-model:open="answerOpen" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer"> <a-modal
<a-typography-paragraph style="white-space: pre-wrap"> v-model:open="answerOpen"
{{ rewriteGregorianDates(answer || '') }} :width="560"
</a-typography-paragraph> centered
<a-space v-if="sources?.length" wrap> destroy-on-close
<RouterLink :body-style="{ paddingTop: `${token.paddingSM}px` }"
v-for="source in sources" @cancel="closeAnswer"
:key="`${source.type}-${source.id}`" >
:to="sourceTo(source)" <template #title>
@click="closeAnswer" <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> <a-typography-paragraph
</RouterLink> :style="{
</a-space> 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> <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> </template>
</a-modal> </a-modal>
</template> </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, ExclamationCircleOutlined,
InfoCircleOutlined, InfoCircleOutlined,
LeftOutlined, LeftOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardAlertDto } from '@mento/shared'; import type { HomeDashboardAlertDto } from '@mento/shared';
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { GiftOutlined } from '@ant-design/icons-vue'; import { GiftOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Flex as AFlex, Flex as AFlex,
+241 -91
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { SearchOutlined, SendOutlined } from '@ant-design/icons-vue'; import { SendOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Input as AInput, Input as AInput,
@@ -48,89 +48,62 @@ const statusMessage = computed(() => {
<template> <template>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="home-hero relative overflow-hidden rounded-2xl border border-emerald-300/50 shadow-sm"> <div class="home-hero">
<!-- base gradient --> <div class="home-hero__gradient" aria-hidden="true" />
<div class="home-hero__gradient pointer-events-none absolute inset-0" aria-hidden="true" /> <div class="home-hero__pattern" aria-hidden="true" />
<!-- geometric pattern --> <div class="home-hero__orb home-hero__orb--start" aria-hidden="true" />
<div class="home-hero__pattern pointer-events-none absolute inset-0" aria-hidden="true" /> <div class="home-hero__orb home-hero__orb--end" 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 <div class="home-hero__row">
class="relative flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 p-4 sm:p-5" <div class="home-hero__greet">
> <div class="home-hero__identity" :aria-label="firstName">
<div class="flex items-center gap-3.5 min-w-0"> <img
<div class="home-hero__identity relative shrink-0" :aria-label="firstName"> v-if="hasCustomPhoto"
<div class="home-hero__frame"> :src="auth.avatarSrc"
<img :alt="firstName"
v-if="hasCustomPhoto" class="home-hero__photo"
:src="auth.avatarSrc" />
:alt="firstName" <span v-else class="home-hero__monogram" aria-hidden="true">
class="home-hero__photo" {{ userInitial }}
/> </span>
<span v-else class="home-hero__monogram" aria-hidden="true">
{{ userInitial }}
</span>
</div>
<span class="home-hero__online" title="آنلاین" aria-hidden="true" /> <span class="home-hero__online" title="آنلاین" aria-hidden="true" />
</div> </div>
<div class="min-w-0"> <div class="home-hero__copy">
<p class="m-0 text-[11px] font-semibold tracking-wide text-emerald-700/80"> <p class="home-hero__brand">{{ brand.nameFa }}</p>
{{ brand.nameFa }} <a-typography-title :level="4" class="home-hero__title">
</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"
>
{{ greeting }}، {{ firstName }} {{ greeting }}، {{ firstName }}
</a-typography-title> </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> </a-typography-text>
</div> </div>
</div> </div>
<div <div class="home-hero__ask">
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>
<a-input <a-input
id="home-hero-ask" id="home-hero-ask"
:value="askQuery" :value="askQuery"
:placeholder="brand.askPlaceholderFa" :placeholder="brand.askPlaceholderFa"
:disabled="asking" :disabled="asking"
:bordered="false" :bordered="false"
class="flex-1 !bg-transparent" class="home-hero__ask-input"
@update:value="emit('update:askQuery', $event)" @update:value="emit('update:askQuery', $event)"
@press-enter="emit('ask')" @press-enter="emit('ask')"
/> />
<kbd <kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</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>
<a-button <a-button
type="primary" type="primary"
shape="round" shape="round"
class="home-hero__ask-btn"
:disabled="asking" :disabled="asking"
:loading="asking" :loading="asking"
aria-label="ارسال" aria-label="ارسال"
@click="emit('ask')" @click="emit('ask')"
> >
<template #icon><SendOutlined /></template> <span class="home-hero__ask-btn-inner">
<span class="hidden sm:inline">بپرس</span> <SendOutlined />
<span class="home-hero__ask-label">بپرس</span>
</span>
</a-button> </a-button>
</div> </div>
</div> </div>
@@ -159,14 +132,39 @@ const statusMessage = computed(() => {
</template> </template>
<style scoped> <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 { .home-hero__gradient {
background: background:
linear-gradient( linear-gradient(
115deg, 115deg,
#ecfdf5 0%, #eef8f7 0%,
#d1fae5 28%, #d0ebe8 28%,
#ccfbf1 55%, #c5e6e2 55%,
#f0fdfa 78%, #eef8f7 78%,
#ffffff 100% #ffffff 100%
); );
} }
@@ -174,37 +172,71 @@ const statusMessage = computed(() => {
.home-hero__pattern { .home-hero__pattern {
opacity: 0.45; opacity: 0.45;
background-image: 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( linear-gradient(
135deg, 135deg,
rgba(20, 184, 166, 0.08) 25%, rgba(16, 137, 125, 0.08) 25%,
transparent 25%, transparent 25%,
transparent 50%, transparent 50%,
rgba(20, 184, 166, 0.08) 50%, rgba(16, 137, 125, 0.08) 50%,
rgba(20, 184, 166, 0.08) 75%, rgba(16, 137, 125, 0.08) 75%,
transparent 75%, transparent 75%,
transparent transparent
); );
background-size: background-size:
18px 18px, 18px 18px,
28px 28px; 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 { .home-hero__identity {
width: 3.25rem; position: relative;
height: 3.25rem; flex-shrink: 0;
} width: 3rem;
height: 3rem;
.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);
} }
.home-hero__photo, .home-hero__photo,
@@ -212,32 +244,150 @@ const statusMessage = computed(() => {
display: flex; display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: calc(1rem - 2px); border-radius: 0.85rem;
object-fit: cover; object-fit: cover;
} }
.home-hero__monogram { .home-hero__monogram {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.15rem; font-size: 1.1rem;
font-weight: 800; font-weight: 800;
color: #ecfdf5; color: #eef8f7;
background: background: linear-gradient(160deg, #16c1a8 0%, #10897d 100%);
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
letter-spacing: -0.02em; letter-spacing: -0.02em;
user-select: none; user-select: none;
} }
.home-hero__online { .home-hero__online {
position: absolute; position: absolute;
left: -1px; inset-inline-start: 0;
bottom: -1px; bottom: 0;
width: 0.85rem; width: 0.7rem;
height: 0.85rem; height: 0.7rem;
border-radius: 999px; border-radius: 999px;
background: #10b981; background: #10897d;
border: 2px solid #fff; 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> </style>
@@ -6,7 +6,7 @@ import {
RiseOutlined, RiseOutlined,
TeamOutlined, TeamOutlined,
WarningOutlined, WarningOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import type { Component } from 'vue'; import type { Component } from 'vue';
import type { HomeDashboardKpiDto } from '@mento/shared'; import type { HomeDashboardKpiDto } from '@mento/shared';
import { currencySuffix } from '@/lib/money'; import { currencySuffix } from '@/lib/money';
@@ -5,7 +5,7 @@ import {
FundOutlined, FundOutlined,
LeftOutlined, LeftOutlined,
PlusOutlined, PlusOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import type { PipelineBoardDto } from '@mento/shared'; import type { PipelineBoardDto } from '@mento/shared';
import { import {
@@ -6,7 +6,7 @@ import {
CheckOutlined, CheckOutlined,
MailOutlined, MailOutlined,
PhoneOutlined, PhoneOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Flex as AFlex, Flex as AFlex,
Tag as ATag, Tag as ATag,
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { TrophyOutlined } from '@ant-design/icons-vue'; import { TrophyOutlined } from '@/icons/ant-bridge';
import { import {
Avatar as AAvatar, Avatar as AAvatar,
Flex as AFlex, Flex as AFlex,
@@ -1,12 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared'; 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 UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification'; import { apiError, faNum } from '@/lib/gamification';
import crownImg from '@/assets/club/crown.png'; import crownImg from '@/assets/club/crown.png';
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [ const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
{ value: 'today', label: 'امروز' }, { value: 'today', label: 'امروز' },
{ value: 'month', label: 'این ماه' }, { value: 'month', label: 'این ماه' },
@@ -40,316 +58,342 @@ onMounted(() => {
}); });
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load)); onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0)); const allItems = computed(() => board.value?.items ?? []);
/** ترتیب نمایش از راست: سوم، اول، دوم */
const podium = computed(() => { interface PodiumSlot {
const [a, b, c] = top3.value; rank: number;
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x); entry: GamificationLeaderEntryDto | null;
}); stepHeight: number;
const rest = computed(() => { avatarSize: number;
const items = board.value?.items ?? []; label: string;
return top3.value.length ? items.slice(top3.value.length) : items; gradient: string;
}); numberColor: string;
function chgClass(change: number | null) { ringColor: string;
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : ''; 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 meOutside = computed(() => {
const b = board.value; const b = board.value;
if (!b?.me) return null; 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> </script>
<template> <template>
<section class="board"> <a-card title="جدول قهرمانان و رتبه‌بندی">
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات"> <a-flex vertical :gap="16">
<button <a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
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>
<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 }"> <a-spin :spinning="loading">
<div <a-flex vertical :gap="16">
v-for="e in podium" <!-- بخش سکوی قهرمانی ۳ پله با ارتفاع‌های نامساوی (Podium) -->
:key="e.userId" <div
class="t-card" v-if="podiumSlots.length"
:class="[`r${e.rank}`, { me: e.isMe }]" :style="{
> position: 'relative',
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" /> borderRadius: `${token.borderRadiusLG}px`,
<span class="t-ava"> background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" /> border: `1px solid ${token.colorBorderSecondary}`,
</span> padding: '24px 12px 0 12px',
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong> overflow: 'hidden',
<small>{{ faNum(e.score) }} امتیاز</small> }"
</div> >
</div> <!-- ردیف ۳ پله قهرمانی هم‌تراز در کف سکو -->
<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 }"> <template v-if="slot.entry">
<span class="rank">{{ toFaDigits(e.rank) }}</span> <span
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" /> :style="{
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b> display: 'inline-flex',
<span class="score">{{ faNum(e.score) }}</span> padding: '2px',
<span class="chg" :class="chgClass(e.change)"> borderRadius: '999px',
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template> background: '#ffffff',
</span> boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
</li> }"
<template v-if="meOutside"> >
<li class="sep" aria-hidden="true">⋯</li> <UserAvatar
<li class="me"> :user-id="slot.entry.userId"
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span> :has-avatar="slot.entry.hasAvatar"
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" /> :name="slot.entry.fullName"
<b class="who">شما</b> :size="slot.avatarSize"
<span class="score">{{ faNum(meOutside.score) }}</span> />
<span class="chg" :class="chgClass(meOutside.change)"> </span>
<template v-if="meOutside.change">
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }} <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> </template>
</span> </a-list>
</li>
</template> <a-empty
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li> v-if="board && !board.items.length"
</ol> description="هنوز کسی در این بازه امتیاز نگرفته است."
</section> />
</a-flex>
</a-spin>
</a-flex>
</a-card>
</template> </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;
}
+4 -4
View File
@@ -108,7 +108,7 @@ onUnmounted(() => {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 0.7rem; gap: 0.7rem;
border-radius: 28px; border-radius: 16px;
background: #fff; background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
} }
@@ -204,7 +204,7 @@ onUnmounted(() => {
gap: 0.28rem; gap: 0.28rem;
padding: 0.65rem 0.7rem 0.55rem; padding: 0.65rem 0.7rem 0.55rem;
border: 0; border: 0;
border-radius: 16px; border-radius: 12px;
background: #f7f8fa; background: #f7f8fa;
cursor: pointer; cursor: pointer;
text-align: start; text-align: start;
@@ -267,7 +267,7 @@ onUnmounted(() => {
min-height: 2.4rem; min-height: 2.4rem;
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
border: 0; border: 0;
border-radius: 999px; border-radius: 10px;
background: #ecfdf8; background: #ecfdf8;
color: #0f766e; color: #0f766e;
font: inherit; font: inherit;
@@ -316,7 +316,7 @@ onUnmounted(() => {
max-height: min(86dvh, calc(100dvh - 3.5rem)); max-height: min(86dvh, calc(100dvh - 3.5rem));
overflow: auto; overflow: auto;
padding: 0.85rem 0.9rem 0.9rem; 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); box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
} }
@@ -11,7 +11,7 @@ import {
CalendarOutlined, CalendarOutlined,
PlusOutlined, PlusOutlined,
UserAddOutlined, UserAddOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
@@ -7,7 +7,7 @@ import {
Menu as AMenu, Menu as AMenu,
MenuItem as AMenuItem, MenuItem as AMenuItem,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { MoreOutlined } from '@ant-design/icons-vue'; import { MoreOutlined } from '@/icons/ant-bridge';
export type ProfileMoreItem = { export type ProfileMoreItem = {
key: string; key: string;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board'; import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
+23 -3
View File
@@ -4,8 +4,9 @@ import {
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
FlagFilled,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Avatar as AAvatar, Avatar as AAvatar,
Button as AButton, Button as AButton,
@@ -13,10 +14,12 @@ import {
Flex as AFlex, Flex as AFlex,
Space as ASpace, Space as ASpace,
Tag as ATag, Tag as ATag,
Tooltip as ATooltip,
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars'; import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } 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), Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
); );
const showTrash = computed(() => props.selectMode && props.selected); 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(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', 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, margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer, background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary, borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary, boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
})); }));
</script> </script>
@@ -194,6 +209,11 @@ const cardStyle = computed(() => ({
{{ money.unit }} {{ money.unit }}
</a-typography-text> </a-typography-text>
</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-flex> </a-flex>
</a-card> </a-card>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref } from 'vue'; import { nextTick, ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue'; import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
import { import {
Button as AButton, Button as AButton,
Dropdown as ADropdown, Dropdown as ADropdown,
@@ -13,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue'; import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue'; import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; 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( const props = withDefaults(
defineProps<{ defineProps<{
@@ -51,6 +51,8 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const bodyEl = ref<HTMLElement | null>(null);
const insertAt = ref<number | null>(null);
const renaming = ref(false); const renaming = ref(false);
const draft = ref(''); const draft = ref('');
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null); const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
margin: 0, margin: 0,
})); }));
function onCardDragOver(stageId: string, event: DragEvent) { function trackDrag(event: DragEvent, index: number | null) {
event.preventDefault(); 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(); event.stopPropagation();
trackDrag(event, pointerDropIndex(index, event));
emit('dragOver', stageId, 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) { function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); 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() { function startRename() {
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
<template> <template>
<section <section
:style="columnStyle" :style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)" @dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)" @drop.prevent="onColumnDrop"
> >
<div :style="headStyle"> <div :style="headStyle">
<span <span
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
</a-dropdown> </a-dropdown>
</div> </div>
<div role="list" :aria-label="stage.title" :style="bodyStyle"> <div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
<div <div
v-for="(deal, index) in stage.deals" v-for="(deal, index) in stage.deals"
:key="deal.id" :key="deal.id"
@dragover="onCardDragOver(stage.id, $event)" @dragover="onCardDragOver(stage.id, index, $event)"
@drop="onCardDrop(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 <DealCard
:deal="deal" :deal="deal"
:accent="stage.color" :accent="stage.color"
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
:select-mode="selectMode" :select-mode="selectMode"
:selected="selectedIds.has(deal.id)" :selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')" @drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)" @open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)" @menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
/> />
</div> </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 <a-typography-text
v-if="!stage.deals.length" v-if="!stage.deals.length"
type="secondary" type="secondary"
@@ -5,7 +5,7 @@ import {
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Avatar as AAvatar, Avatar as AAvatar,
Button as AButton, Button as AButton,
@@ -9,8 +9,31 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiField, UiSelect } from '@/components/ui'; import {
import UiIcon from '@/components/UiIcon.vue'; 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'; type Step = 'target' | 'map' | 'review';
@@ -31,10 +54,23 @@ const stageMap = ref<Record<string, string>>({});
const busy = ref(false); const busy = ref(false);
const error = ref(''); const error = ref('');
const stepIndex = computed(() => {
if (step.value === 'target') return 0;
if (step.value === 'map') return 1;
return 2;
});
const selectedTarget = computed( const selectedTarget = computed(
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null, () => 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 canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
const canContinueMap = computed(() => { const canContinueMap = computed(() => {
const current = preview.value; 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'>) { function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
if (stage.isWon) return 'برد'; if (stage.isWon) return 'برد';
if (stage.isLost) return 'باخت'; if (stage.isLost) return 'باخت';
@@ -171,298 +214,181 @@ watch(targetId, () => {
</script> </script>
<template> <template>
<div class="merge-wizard"> <a-flex vertical :gap="16">
<ol class="steps" aria-label="مراحل ادغام کاریز"> <a-steps :current="stepIndex">
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li> <a-step title="مقصد" />
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li> <a-step title="نگاشت" />
<li :class="{ active: step === 'review' }">۳. بازبینی</li> <a-step title="بازبینی" />
</ol> </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"> <a-form layout="vertical">
<section v-if="step === 'target'" class="step-panel"> <a-form-item label="کاریز مقصد" required>
<div class="intro"> <a-select
<UiIcon name="layers" :size="20" /> v-model:value="targetId"
<div> :options="targetOptions"
<h3>کاریز مقصد را انتخاب کنید</h3> placeholder="انتخاب کنید"
<p> style="width: 100%"
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند. />
</p> </a-form-item>
</div> </a-form>
</div>
<UiField label="کاریز مقصد" required> <a-row :gutter="[16, 16]">
<UiSelect v-model="targetId" required> <a-col :xs="24" :md="8">
<option value="" disabled>انتخاب کنید</option> <a-card :bordered="true">
<option v-for="target in preview.targets" :key="target.id" :value="target.id"> <a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
{{ target.name }} <br />
</option> <a-typography-text type="secondary">معامله منتقل می‌شود</a-typography-text>
</UiSelect> </a-card>
</UiField> </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"> <a-alert
<article> v-if="selectedTarget?.isDefault"
<strong>{{ toFaDigits(preview.dealCount) }}</strong> type="info"
<span>معامله منتقل می‌شود</span> show-icon
</article> message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
<article> />
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong> </a-flex>
<span>فرم سرنخ به مقصد وصل می‌شود</span>
</article>
<article v-if="preview.source.isDefault" class="warn">
<strong>هشدار</strong>
<span>کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود.</span>
</article>
</div>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p> <a-flex v-else-if="step === 'map'" vertical :gap="16">
</section> <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"> <a-form layout="vertical">
<div class="intro"> <a-row
<UiIcon name="target" :size="20" /> v-for="sourceStage in preview.stages"
<div> :key="sourceStage.id"
<h3>مرحله‌های مبدا را نگاشت کنید</h3> :gutter="[16, 0]"
<p>هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.</p> align="middle"
</div> >
</div> <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"> <a-flex v-else vertical :gap="16">
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row"> <a-flex :gap="8" align="flex-start">
<div class="stage-info"> <CheckCircleOutlined />
<strong>{{ sourceStage.name }}</strong> <a-flex vertical :gap="4">
<span> <a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله <a-typography-paragraph type="secondary" :style="{ margin: 0 }">
</span> «{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
</div> {{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]"> کاریز «{{ preview.source.name }}» حذف می‌شود.
<option value="" disabled>مرحله مقصد</option> </a-typography-paragraph>
<option </a-flex>
v-for="targetStage in compatibleTargets(sourceStage)" </a-flex>
:key="targetStage.id"
:value="targetStage.id"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<section v-else class="step-panel"> <a-descriptions :column="1" size="small" bordered>
<div class="intro"> <a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
<UiIcon name="check" :size="20" /> <a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
<div> <a-descriptions-item label="روش فرم‌ها">
<h3>بازبینی نهایی</h3> همه فرم‌های سرنخ به مقصد وصل می‌شوند.
<p> </a-descriptions-item>
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود. </a-descriptions>
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود. </a-flex>
کاریز «{{ preview.source.name }}» حذف می‌شود. </template>
</p>
</div>
</div>
<dl class="review-list"> <a-typography-text v-else-if="!busy && !error" type="secondary">
<div> <template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<dt>مبدا</dt> <template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
<dd>{{ preview.source.name }}</dd> </a-typography-text>
</div> </a-spin>
<div>
<dt>مقصد</dt>
<dd>{{ selectedTarget?.name }}</dd>
</div>
<div>
<dt>روش فرم‌ها</dt>
<dd>همه فرم‌های سرنخ به مقصد وصل می‌شوند.</dd>
</div>
</dl>
</section>
</template>
<p v-else-if="!busy && !error" class="muted"> <a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
<p v-if="error" class="error">{{ error }}</p> <a-space wrap>
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
<div class="actions"> <a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton> <a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'"> <a-button
قبلی v-if="step === 'target'"
</UiButton> type="primary"
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'"> :disabled="!preview || !canContinueTarget"
قبلی @click="nextFromTarget"
</UiButton> >
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
ادامه ادامه
</UiButton> </a-button>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap"> <a-button
v-else-if="step === 'map'"
type="primary"
:disabled="!canContinueMap"
@click="nextFromMap"
>
بازبینی بازبینی
</UiButton> </a-button>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge"> <a-button
v-else
type="primary"
danger
:loading="busy"
:disabled="!canContinueMap"
@click="submitMerge"
>
ادغام و حذف مبدا ادغام و حذف مبدا
</UiButton> </a-button>
</div> </a-space>
</div> </a-flex>
</template> </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 { import {
AppstoreOutlined, AppstoreOutlined,
FundOutlined, FundOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
import { import {
Flex as AFlex, Flex as AFlex,
InputNumber as AInputNumber, InputNumber as AInputNumber,
@@ -10,7 +10,7 @@ import {
Tag as ATag, Tag as ATag,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue'; import { CloseOutlined } from '@/icons/ant-bridge';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines'; import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
+1 -1
View File
@@ -17,7 +17,7 @@ import {
SyncOutlined, SyncOutlined,
TeamOutlined, TeamOutlined,
WarningOutlined, WarningOutlined,
} from '@ant-design/icons-vue'; } from '@/icons/ant-bridge';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
@@ -7,11 +7,11 @@ import {
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Tag as ATag, Tag as ATag,
Typography, Typography,
} from 'ant-design-vue'; } 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 type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue'; import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue'; import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
@@ -132,7 +132,8 @@ function onCreated(t: RoutineTaskDto) {
:style="{ marginTop: '12px' }" :style="{ marginTop: '12px' }"
/> />
<a-spin :spinning="loading && !items.length"> <CrmSkeleton :loading="loading && !items.length">
<CrmSkeletonList :rows="4">
<a-empty <a-empty
v-if="!loading && !items.length" v-if="!loading && !items.length"
:description="emptyTitle" :description="emptyTitle"
@@ -185,7 +186,8 @@ function onCreated(t: RoutineTaskDto) {
/> />
</template> </template>
</a-flex> </a-flex>
</a-spin> </CrmSkeletonList>
</CrmSkeleton>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" /> <QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" /> <RoutineTaskModals :actions="actions" />

Some files were not shown because too many files have changed in this diff Show More