63 Commits
Author SHA1 Message Date
MahdiandCursor 70524f4f2c Allow treasury payments when account balance goes negative.
Remove insufficient-balance blocks on document create, settle, and party collection so income/expense posting can overdraw an account.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 16:43:25 +03:30
MahdiandCursor 1b08ab1230 Show selected account balance beside the treasury account field.
Color the balance green, red, or gray on income and expense forms so users see cash position before posting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 13:48:30 +03:30
MahdiandCursor 4409ded0ab Add treasury document photos, table list, and manage permission.
Income/expense lists are table-or-card with detail, edit, void, and delete; edit/delete require owner, admin, or the new finance manage permission.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 12:42:01 +03:30
MahdiandCursor 3696713443 Fix routines manage select popup typing for vue-tsc.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:48:48 +03:30
MahdiandCursor d282627af7 Add searchable assignee and creator filters on the routines manage toolbar.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:45:37 +03:30
MahdiandCursor fb6aa77465 Omit createdBy relation when duplicating a routine so API types still compile.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:16:12 +03:30
MahdiandCursor 54d360ded8 Show routine creator in the manage list so managers can see who defined each routine.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:07:41 +03:30
MahdiandCursor 717a41e94b Substitute org name and balance tokens in outbound SMS so templates no longer leave {از طرف} literal.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 17:12:31 +03:30
MahdiandCursor f36fddc23b Allow inserting dynamic field tokens while creating SMS templates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 14:55:25 +03:30
MahdiandCursor e04aa14b84 Add org-scoped reusable SMS templates in the compose modal.
Users can save, pick, and delete prepared messages shared across the organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 14:15:42 +03:30
MahdiandCursor 3c27fae626 Apply overdue/today/upcoming date colors on sales deal cards as well.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 13:13:40 +03:30
MahdiandCursor 33eeb3242a Add activity priority desk filter and keep detail browsing inside the same column after stage changes.
When done/reopen/stage moves leave a column, advance to the next activity there or return to the list if empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 12:02:13 +03:30
MahdiandCursor ae16e43013 Clarify activity card due tones: overdue as عقب افتاده, done as انجام شده, and today in warning yellow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:52:56 +03:30
MahdiandCursor 7a55b84b81 Add org-scoped activity priority labels/colors and apply them across forms, boards, and routines.
Also fix activity leave-without-save confirm, kanban due-date labels with tone-colored peek chevron, and rename the types manager button.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:39:15 +03:30
MahdiandCursor a1ac677b3a Combine data-access and contact list facets with OR so each selected dimension grants visibility.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 20:15:21 +03:30
MahdiandCursor b00465439e Drop groupIds from customer data-access filters; keep visibility via group assignees only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:50:02 +03:30
MahdiandCursor 9af0e9e94e Fix customer groups modal scroll nesting and make assignees removable via chips.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:07:36 +03:30
MahdiandCursor b745b6d460 Tighten team users table with a compact row-number column and fixed layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:30:49 +03:30
MahdiandCursor fec5ece255 Improve deal board activity cues with relative dates and a quick activity list popup.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:27:32 +03:30
MahdiandCursor f4f2311ae4 Improve team settings responsive layout, narrower role panel, and mobile pagination.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:31:58 +03:30
MahdiandCursor 57e0fdd600 Improve team settings search, user sorting, and role badge layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:01:08 +03:30
MahdiandCursor b583858088 feat(customers): add optional group assignees with OR visibility for people, companies, and birthdays
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 12:59:49 +03:30
MahdiandCursor 453ff1a5f6 fix(activities): allow assignees to attach files when commenting under creator-only edit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:49:22 +03:30
MahdiandCursor 68fcece743 fix(activities): keep comment and postpone open for assignees under creator-only edit
Allow dueAt/description patches for collaborators when editPolicy is creator-only, and show the no-edit warning only when they try to change locked fields—not as a permanent banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:23:10 +03:30
MahdiandCursor de77066cb0 feat(tahesab): add manual sync to refresh linked customer profiles
Expose sync/customers-now and a settings button so existing linked people get national id, address, city, and rasteh without waiting for the schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:54:59 +03:30
MahdiandCursor d23dd20237 feat(tahesab): sync full customer profile fields into CRM people
Pull national id, address, city, and rasteh on import/refresh so person records match Tahesab, and surface them on the person form and profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:31:47 +03:30
MahdiandCursor 8a4bd70ad4 fix(boards): keep kanban card drag from locking after a few moves
Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:58:19 +03:30
MahdiandCursor d87321c7bf fix(activities): type ActivityRow with editPolicy for API builds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:01:16 +03:30
MahdiandCursor 1a05eee46d feat(activities): let creators lock edit and delete to themselves
Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:56:36 +03:30
MahdiandCursor ec9a7cf9c9 feat(deals): plan activities on the deal and create them from the board
Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:06:20 +03:30
MahdiandCursor 4f08ffae36 fix(web): use nowrap for StagePathStrip flex wrap prop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:59:06 +03:30
MahdiandCursor d7cb92bf81 feat(web): add stage path strip with Didar-style move confirm
Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:55:43 +03:30
MahdiandCursor 871d7bba1a fix(activities): keep board filters and make card drops reliable
Return to the filtered desk after marking an activity done, widen the detail title, and stop a stuck column-drag id from blocking card moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:05:26 +03:30
MahdiandCursor f13a1249af feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30
MahdiandCursor fdf1fbe4aa feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 20:10:16 +03:30
MahdiandCursor 6df198321a feat(activities): remove an assignee from the share list
A team, role, or person can be taken off the activity from the progress chips, and ungrouped people use the same chip as grouped members.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 18:43:02 +03:30
MahdiandCursor 4c8604d346 feat(activities): pick assignees by team or role
A team or role adds every member in one step, while each person's completion share stays unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:39:51 +03:30
MahdiandCursor 25a24772fc feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:59:32 +03:30
MahdiandCursor 932dc3f1c1 feat(billing): start demos on the core plan and apply tariff levels
New signups get the core module set with the 7-day demo. Superadmin can apply core, team, organization, or dedicated and the owner's environments take that level's access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:08:14 +03:30
MahdiandCursor 5b26b9156d fix(treasury): restore the money parser import on expenses
The phone layout used the amount parser without importing it, so the web type check blocked the push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:33:17 +03:30
MahdiandCursor d12d7b91cb feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:28:27 +03:30
MahdiandCursor 49459707cb fix(treasury): show a party's net balance as debtor or creditor
Income and expense forms now use the same receivable-minus-payable figure beside the party.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 11:09:51 +03:30
MahdiandCursor ba7f942059 fix(treasury): apply a full receipt or payment to the party's open balance
A settled document of the same amount no longer leaves an existing remaining unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 10:45:59 +03:30
MahdiandCursor 96105468d4 fix(activities): keep each assignee status attached to that person
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:45:32 +03:30
MahdiandCursor 7a8007fff0 feat(activities): keep multi-assignee completion readable for large teams
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:30:05 +03:30
MahdiandCursor da851bd9e5 feat(activities): let every assignee finish their own share
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 20:48:43 +03:30
MahdiandCursor dae3ae3f7e fix(notes): keep card titles visible when the square card is crowded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 18:34:23 +03:30
MahdiandCursor b03d97dd9e fix(activities): show each note as its own dated message
The colon inside the clock was splitting the stamp from the comment, so date, author, text, and attachments now stay together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:39:06 +03:30
MahdiandCursor 8b8d01976d fix(pipeline): hide prior-only sales paths from current work
The board lists a pipeline in current work only when it has a deal in that period, and keeps archive-only paths for the prior-data view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 15:09:49 +03:30
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30
MahdiandCursor 3c9b906bd6 fix(leads): keep the filter panel from shifting the page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:58:18 +03:30
MahdiandCursor ef58922e13 feat(leads): gather filters in one popover like activities
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:24:45 +03:30
MahdiandCursor 06036b00d3 feat(finance): open payroll details from mobile cards
Show each payroll as a card on a phone and slide its details up from the bottom so a short sheet leaves the top of the screen clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 18:13:38 +03:30
MahdiandCursor d33d88e76d feat(finance): show payroll details and search payment periods
Keep long reasons out of the report table, open them with payments in a details view, and let a payment period be found by name or month.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 17:34:12 +03:30
MahdiandCursor 5ef693990c feat(finance): edit payroll with month ranges, reasons, and toman
Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
152 changed files with 15314 additions and 2571 deletions
-2
View File
@@ -52,7 +52,6 @@ jobs:
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
if: false
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
@@ -62,7 +61,6 @@ jobs:
- name: Push Docker images
id: push
if: false
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
@@ -0,0 +1,28 @@
-- مسئولان اختیاری روی گروه‌های مشتری
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
+44 -2
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled
}
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority {
low
medium
@@ -151,6 +162,10 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// قالب‌های آمادهٔ پیامک دستی ([{ id, name, body }, …])
smsTemplates Json @default("[]")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime?
isActive Boolean @default(true)
@@ -295,6 +310,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
@@ -565,6 +581,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[]
companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -883,6 +901,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime?
completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([])
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -2447,8 +2471,11 @@ model FinancialPayroll {
period String
baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2)
dueDate DateTime
notes String?
@@ -2953,18 +2980,28 @@ model MontifyWebhookDelivery {
@@index([connectionId, createdAt])
}
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی)
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده)
model VoipConnection {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String
/// generic | daftareshoma
provider String @default("generic")
enabled Boolean @default(true)
webhookSecretCiphertext String?
webhookSecretIv String?
webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic)
clickToCallUrl String?
/// توکن Bearer API دفتر شما (رمزنگاری‌شده)
apiTokenCiphertext String?
apiTokenIv String?
apiTokenTag String?
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
fromNumber String?
/// خط/داخلی سازمان در دفتر شما (caller_extension)
callerExtension String?
lastEventAt DateTime?
lastError String?
calls VoipCallLog[]
@@ -2972,6 +3009,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt
@@index([organizationId, enabled])
@@index([organizationId, provider])
}
model VoipCallLog {
@@ -2986,6 +3024,8 @@ model VoipCallLog {
status String @default("completed")
fromNumber String?
toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String?
activityId String?
durationSec Int?
@@ -3010,6 +3050,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId])
@@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt])
@@ -7,6 +7,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
Res,
StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto,
UpdateActivityBoardDto,
UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto,
UpdateActivityTypeDto,
} from './dto';
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true };
}
@Get('priorities')
listPriorities(@CurrentUser() user: Actor) {
return this.typesService.listPriorities(user.organizationId);
}
@Put('priorities')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
return this.typesService.updatePriorities(user.organizationId, dto.items);
}
@Get('types')
listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId);
@@ -196,6 +210,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
);
@@ -232,6 +247,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -265,6 +281,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -438,8 +455,16 @@ export class ActivitiesController {
}
@Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
complete(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Query('force') force?: string,
@Query('undo') undo?: string,
) {
return this.activitiesService.complete(user.organizationId, id, user, {
force: force === '1' || force === 'true',
undo: undo === '1' || undo === 'true',
});
}
@Delete(':id')
+283 -30
View File
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
function assigneeIdList(row: {
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
}): string[] {
const ids = row.assignees?.map((item) => item.id) ?? [];
if (ids.length) return [...new Set(ids)];
return row.assigneeId ? [row.assigneeId] : [];
}
function canForceClose(
actor: { userId: string; role: string },
createdById: string | null | undefined,
): boolean {
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
}
function canMutateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
editPolicy?: 'creator' | 'assignees' | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
return assigneeIdList(row).includes(actor.userId);
}
/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */
function canCollaborateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
return assigneeIdList(row).includes(actor.userId);
}
/** فقط dueAt / description → تعویق و ثبت نظر */
function isCollaboratorPatch(dto: UpdateActivityRequest): boolean {
const keys = Object.keys(dto).filter(
(key) => (dto as Record<string, unknown>)[key] !== undefined,
);
return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description');
}
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
}
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -64,6 +123,7 @@ type ListFacets = {
customer?: string;
/** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset';
priority?: ActivityDto['priority'];
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
};
@@ -82,6 +142,9 @@ type ActivityRow = {
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string;
promotedFromPriorAt?: Date | null;
tags: string[];
@@ -186,22 +249,39 @@ export class ActivitiesService {
return { stages, types };
}
/** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */
private deskScope(
actor: ActivityActor,
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput {
if (desk !== 'mine') return activityAccessWhere(actor);
return {
AND: [
{
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
},
{
NOT: {
completionMode: 'all',
status: 'open',
completedByIds: { has: actor.userId },
},
},
],
};
}
async summary(
organizationId: string,
actor: ActivityActor,
desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
@@ -534,8 +614,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
select: {
status: true,
completedAt: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (
stage.kind === 'done' &&
existing?.completionMode === 'all' &&
assigneeIdList(existing).length > 1 &&
!canForceClose(actor, existing.createdById)
) {
return this.complete(organizationId, id, actor);
}
const row = await this.prisma.activity.update({
where: { id },
data: {
@@ -550,6 +646,9 @@ export class ActivitiesService {
: stage.kind === 'open'
? null
: undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
},
include,
});
@@ -675,6 +774,9 @@ export class ActivitiesService {
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
: undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId,
},
include,
@@ -743,8 +845,31 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
select: {
status: true,
type: true,
typeDefId: true,
fields: true,
participantIds: true,
completionMode: true,
completedByIds: true,
editPolicy: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const collabOnly = isCollaboratorPatch(dto);
if (collabOnly) {
if (!canCollaborateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید');
}
} else if (!canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) {
@@ -812,6 +937,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
where: { id, organizationId },
@@ -839,6 +965,7 @@ export class ActivitiesService {
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +979,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
}
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
if (modeChanged) data.completionMode = nextMode;
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
data.editPolicy = dto.editPolicy;
}
const prevAssigneeIds = assigneeIdList(existing ?? {});
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
const explicitDone = dto.status === 'done';
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
const explicitCancel = dto.status === 'canceled';
if (explicitDone) {
data.status = 'done';
data.completedAt = new Date();
} else if (explicitReopen) {
data.status = 'open';
data.completedAt = null;
data.completedByIds = [];
} else if (explicitCancel) {
data.status = 'canceled';
} else if (nextMode === 'any' && modeChanged) {
data.completedByIds = [];
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
data.completedByIds = reconciled.completedByIds;
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
if (reconciled.status === 'done' && existing?.status !== 'done') {
data.status = 'done';
data.completedAt = new Date();
} else if (reconciled.status === 'open' && existing?.status === 'done') {
data.status = 'open';
data.completedAt = null;
}
}
}
const row = await this.prisma.activity.update({
@@ -881,7 +1040,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
);
}
if (dto.status === 'done' && existing?.status !== 'done') {
if (data.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +1050,74 @@ export class ActivitiesService {
organizationId: string,
id: string,
actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
select: {
status: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
if (existing.status === 'canceled') {
throw new BadRequestException('فعالیت لغو شده است');
}
const assignees = assigneeIdList(existing);
const allowedForce = canForceClose(actor, existing.createdById);
if (options.force && !allowedForce) {
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
}
if (options.undo) {
if (existing.completionMode !== 'all' || existing.status !== 'open') {
throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد');
}
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
},
include,
});
return this.toDto(row, await this.ctx(organizationId));
}
const personal =
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
if (!personal) {
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
if (existing.status !== 'done') await this.fireDone(organizationId, row);
return this.toDto(row, await this.ctx(organizationId));
}
if (!assignees.includes(actor.userId)) {
throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند');
}
const reconciled = reconcileAllShares(assignees, [
...existing.completedByIds,
actor.userId,
]);
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include,
});
if (existing?.status !== 'done') {
if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -946,6 +1161,18 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing || !canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
}
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true },
@@ -966,6 +1193,19 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
// پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator
if (!gate || !canCollaborateActivity(actor, gate)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید');
}
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
@@ -1060,10 +1300,27 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
const canMutate = !!gate && canMutateActivity(actor, gate);
const canRemoveOwn =
!!gate &&
canCollaborateActivity(actor, gate) &&
row.uploadedById === actor.userId;
if (!canMutate && !canRemoveOwn) {
throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید');
}
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
@@ -1108,25 +1365,18 @@ export class ActivitiesService {
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim();
const facetPriority = facets?.priority;
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetPriority ? [{ priority: facetPriority }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee
@@ -1418,6 +1668,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [],
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import {
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green';
}
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { activityPriorities: true },
});
return parseActivityPrioritySettings(row?.activityPriorities);
}
async updatePriorities(
organizationId: string,
items: Array<{ key: ActivityPriority; label: string; color: string }>,
): Promise<ActivityPrioritySetting[]> {
const byKey = new Map(items.map((i) => [i.key, i]));
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
}
const hex = /^#[0-9a-fA-F]{6}$/;
const stored: Record<string, { label: string; color: string }> = {};
for (const key of ACTIVITY_PRIORITY_KEYS) {
const item = byKey.get(key)!;
const label = item.label.trim().slice(0, 40);
const color = item.color.trim();
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
// فقط تفاوت با پیش‌فرض را نگه می‌داریم تا JSON جمع‌وجور بماند
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
stored[key] = { label, color };
}
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { activityPriorities: stored as Prisma.InputJsonValue },
});
return parseActivityPrioritySettings(stored);
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+46
View File
@@ -9,11 +9,13 @@ import {
IsObject,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -91,6 +93,11 @@ export class ListActivitiesQueryDto {
@IsString()
typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
@@ -384,6 +391,14 @@ export class CreateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
export class UpdateActivityDto {
@@ -484,4 +499,35 @@ export class UpdateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
export class ActivityPriorityItemDto {
@IsIn(PRIORITY_KEYS)
key!: (typeof PRIORITY_KEYS)[number];
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/)
color!: string;
}
export class UpdateActivityPrioritiesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ActivityPriorityItemDto)
items!: ActivityPriorityItemDto[];
}
+11 -3
View File
@@ -19,6 +19,10 @@ import type {
StorageUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
},
});
const role = membership?.role ?? user.role;
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: [];
const orgName = membership?.organization.name;
const sub =
subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
permissions: permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}),
});
}
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
},
select: { userId: true },
});
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules,
isPermanentBillingPlan,
isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
}
defaultModulesForNewOrg(): OrgModuleKey[] {
return defaultEnabledModules();
return modulesForProductPlan('core');
}
/** سطح امکانات را روی همهٔ محیط‌های مالک می‌گذارد و سقف هسته/تیم/سازمان را هم تنظیم می‌کند. */
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
const modules = modulesForProductPlan(code);
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
for (const id of orgIds) {
await this.setOrgModules(id, modules);
}
const limits = PRODUCT_PLAN_LIMITS[code];
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
const account = await this.getByOwnerUserId(owner.userId);
if (account) {
await this.prisma.billingAccount.update({
where: { id: account.id },
data: {
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
...(limits.maxOrganizations != null
? { maxOrganizations: limits.maxOrganizations }
: {}),
},
});
}
}
return modules;
}
async countExpiringSoon() {
+138 -109
View File
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
return out;
}
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
if (!facets.length) return null;
return facets.length === 1 ? facets[0]! : { OR: facets };
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
const facets: Prisma.PersonWhereInput[] = [];
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
const ids = ownerIdList(rule, ctx);
if (ids.length) {
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
}
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) {
and.push({
facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
facets.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
return unionFacets(facets);
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
@@ -135,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
return parts.length === 1 ? parts[0]! : { OR: parts };
}
/** اعضای گروه‌هایی که کاربر مسئولشان است */
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
@@ -149,6 +170,8 @@ export function customersPersonWhere(
});
if (w) parts.push(w);
}
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts);
}
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
// حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
const facets: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
if (ids.length) {
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
}
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee)
if (rule.sources?.length) {
and.push({
facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts);
}
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
const facets: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
if (ids.length) {
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
facets.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
const facets: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
if (ids.length) {
facets.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
facets.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -266,32 +285,33 @@ export function activitiesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
const facets: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
if (ids.length) {
facets.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
facets.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
facets.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -310,34 +330,35 @@ export function notesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
const facets: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
if (ids.length) {
facets.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
facets.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return {
AND: [personalGate, orParts(parts)],
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
const facets: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
if (ids.length) {
facets.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
}
if (rule.sources?.length) {
and.push({
facets.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
const facets: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
if (ids.length) {
facets.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
facets.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
const facets: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
if (ids.length) {
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
facets.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
} else if (
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true;
}
}
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere,
} from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere(
actor: ScopedActor,
actor: DeepAccessActor,
scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
}
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
const deep = deepOwnerIds(actor, 'activities');
const ids =
deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return {
OR: [
{ assigneeId: { in: ids } },
+68 -1
View File
@@ -21,15 +21,82 @@ function tehranJalaliLong(d: Date) {
return `${toFaDigits(j.jd)} ${jalaliMonthName(j.jm)} ${toFaDigits(j.jy)}`;
}
/** قالب بس/بد مثل UI ته‌حساب — برای توکن‌های مانده در پیامک */
export function formatSmsBedBes(v: unknown, maximumFractionDigits = 3): string {
if (v == null) return '';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('en-US', { maximumFractionDigits }).format(Math.abs(n)),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}
const METAL_FIELD_LABELS: Record<string, string> = {
MandeyeVazni: 'طلا',
MandeyeVazniNoghre: 'نقره',
MandeyeKolBeTALA: 'کل به طلا',
MandeMenhayeSeke: 'منهای سکه',
};
function isMetalMandehKey(key: string) {
if (METAL_FIELD_LABELS[key]) return true;
if (key.startsWith('Seke_')) return true;
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
}
function metalFieldLabel(key: string) {
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
return key;
}
/** از فیلدهای مانده ته‌حساب، متن مانده ریالی و فلزی می‌سازد */
export function smsBalanceTexts(input: {
mandehFields?: Array<{ key: string; value: string | null }> | null;
rialBalance?: number | null;
goldBalance?: number | null;
}): { rialBalance: string; metalBalance: string } {
const fields = input.mandehFields ?? [];
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
const rial =
formatSmsBedBes(mali?.value ?? input.rialBalance) ||
formatSmsBedBes(input.rialBalance);
const metalParts = fields
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
.map((f) => `${metalFieldLabel(f.key)} ${formatSmsBedBes(f.value)}`.trim())
.filter(Boolean);
const metal =
metalParts.join('، ') ||
(input.goldBalance != null && formatSmsBedBes(input.goldBalance)
? `طلا ${formatSmsBedBes(input.goldBalance)}`
: '');
return { rialBalance: rial, metalBalance: metal };
}
export function renderSmsText(
text: string,
vars: { name?: string; phone?: string; title?: string; company?: string },
vars: {
name?: string;
phone?: string;
title?: string;
company?: string;
from?: string;
rialBalance?: string;
metalBalance?: string;
},
) {
return applyMessageTemplate(text, {
name: vars.name,
phone: vars.phone,
title: vars.title,
company: vars.company,
from: vars.from,
rialBalance: vars.rialBalance,
metalBalance: vars.metalBalance,
date: tehranJalaliLong(new Date()),
}).trim();
}
+1
View File
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return {
year: today.jy,
month: today.jm,
monthName: jalaliMonthName(today.jm),
today: today.jd,
+8 -3
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
};
const [rows, total] = await this.prisma.$transaction([
@@ -165,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
assignees: [],
}));
}
+2
View File
@@ -3,6 +3,7 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule,
BehaviorModule,
CustomerGroupsModule,
NotificationsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1)
@MaxLength(500)
text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
}
export class BulkDeletePeopleDto {
+2 -1
View File
@@ -139,6 +139,7 @@ export class PeopleController {
}
@Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user);
}
@@ -171,7 +172,7 @@ export class PeopleController {
@Param('id') id: string,
@Body() dto: BirthdaySmsDto,
) {
return this.peopleService.sendBirthdaySms(user, id, dto.text);
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
}
@Get(':id')
+199 -56
View File
@@ -22,6 +22,7 @@ import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -48,13 +49,15 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { renderSmsText, smsBalanceTexts } from '../common/sms-text';
import { extractMandehRaw } from '../integrations/tahesab/tahesab-methods';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
import {
@@ -157,6 +160,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {}
private personListWhere(
@@ -216,13 +220,27 @@ export class PeopleService implements OnModuleInit {
}
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
const facets: Prisma.PersonWhereInput[] = [];
if (groupIds.length) {
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
}
if (moods.length) {
facets.push({ mood: { in: moods } });
}
if (sources.length) {
facets.push({ importSource: { in: sources } });
}
if (facets.length === 1) and.push(facets[0]!);
else if (facets.length > 1) and.push({ OR: facets });
return {
organizationId: actor.organizationId,
...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}),
...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
...and,
],
};
}
@@ -345,6 +363,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0,
companyCount: 0,
assignees: [],
}));
}
@@ -680,8 +699,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null },
...customersPersonWhere(actor),
},
select: {
id: true,
@@ -728,27 +747,50 @@ export class PeopleService implements OnModuleInit {
};
}
async sendBirthdaySms(actor: Actor, personId: string, text: string) {
async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId },
where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
position: true,
ownerId: true,
birthDate: true,
company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
},
});
if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) {
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.');
const assigneeIds = new Set(
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
);
const canSend =
canViewAllRecords(actor.role) ||
person.ownerId === actor.userId ||
assigneeIds.has(actor.userId);
if (!canSend) {
throw new ForbiddenException(
'فقط مالک مشتری یا مسئول گروه او می‌تواند پیامک تبریک بفرستد.',
);
}
if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -770,12 +812,110 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const fullName = `${person.firstName} ${person.lastName}`.trim();
const message = renderSmsText(body, {
name: fullName,
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
if (!force) {
const prior = await this.prisma.outboundSmsLog.findFirst({
where: {
organizationId: actor.organizationId,
personId: person.id,
status: 'sent',
sourceId: birthdayKey,
NOT: { actorUserId: actor.userId },
},
orderBy: { createdAt: 'desc' },
select: { actorUserId: true },
});
if (prior) {
const priorUser = prior.actorUserId
? await this.prisma.user.findFirst({
where: { id: prior.actorUserId },
select: { fullName: true },
})
: null;
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
throw new ConflictException(
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا می‌خواهید دوباره بفرستید؟`,
);
}
}
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
title: fullName,
company: person.company?.name ?? '',
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
mobile,
body: message,
source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
actorUserId: actor.userId,
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
const senderName = (
await this.prisma.user.findFirst({
where: { id: actor.userId },
select: { fullName: true },
})
)?.fullName?.trim() || 'یکی از مسئولان';
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
await Promise.all(
peers.map((userId) =>
this.notifications.create({
organizationId: actor.organizationId,
userId,
type: 'birthday_sms_sent',
title: 'پیامک تبریک تولد ارسال شد',
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است می‌توانید دوباره بفرستید.`,
link: `/contacts/people/${person.id}`,
entityType: 'person',
entityId: person.id,
dedupe: false,
}),
),
);
return { ok: true };
}
async sendSms(actor: Actor, personId: string, text: string) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText(
body,
await this.personSmsVars(actor.organizationId, person.id, {
name: person.fullName,
phone: mobile,
title: person.position?.trim() || '',
company: person.company?.name ?? '',
}),
);
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
@@ -805,47 +945,50 @@ export class PeopleService implements OnModuleInit {
return { ok: true };
}
async sendSms(actor: Actor, personId: string, text: string) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
/** نام سازمان + مانده‌های کش‌شدهٔ ته‌حساب برای توکن‌های پیامک */
private async personSmsVars(
organizationId: string,
personId: string,
base: {
name: string;
phone: string;
title: string;
company: string;
},
) {
const [org, link] = await Promise.all([
this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
}),
this.prisma.personAccountingLink.findFirst({
where: { personId, connection: { organizationId } },
orderBy: [
{ connection: { isDefault: 'desc' } },
{ linkedAt: 'desc' },
],
include: { balanceCache: true },
}),
]);
let rialBalance = '';
let metalBalance = '';
const cache = link?.balanceCache;
if (cache) {
const rawView = extractMandehRaw(cache.rawResponse);
const bal = smsBalanceTexts({
mandehFields: rawView.fields,
rialBalance: cache.rialBalance,
goldBalance: cache.goldBalance,
});
rialBalance = bal.rialBalance;
metalBalance = bal.metalBalance;
}
const person = await this.get(actor, personId);
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const message = renderSmsText(body, {
name: person.fullName,
phone: mobile,
title: person.fullName,
company: person.company?.name ?? '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
organizationId: actor.organizationId,
phone: mobile,
text: message,
source: 'manual',
});
await this.outboundSms.record({
organizationId: actor.organizationId,
personId: person.id,
mobile,
body: message,
source: 'manual',
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
actorUserId: actor.userId,
});
if (!result.ok) {
throw new ServiceUnavailableException(
result.blocked
? 'ارسال پیامک به‌خاطر محدودیت اعتبار مسدود است.'
: result.reason === 'SMS not configured'
? 'ارسال پیامک روی سرور فعال نیست.'
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
return { ok: true };
return {
...base,
from: org?.name?.trim() || '',
rialBalance,
metalBalance,
};
}
listSmsHistory(actor: Actor, personId: string, limit?: number) {
@@ -8,7 +8,14 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
class UpdateCustomerGroupDto {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
@Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
},
});
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: { name: string; color?: CustomerGroupColor },
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
name,
color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
},
});
} catch (e) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> {
await this.require(organizationId, id);
const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try {
await this.prisma.customerGroup.update({
where: { id },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
});
} catch (e) {
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null;
}
private toDto(r: {
id: string;
name: string;
color: string;
position: number;
tahesabGid: string | null;
parentId: string | null;
parent?: { name: string } | null;
_count: { people: number; companies: number };
assignees: Array<{ id: string; fullName: string }>;
}): CustomerGroupDto {
return {
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
};
}
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: unique },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId },
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import {
assertFactorCode,
buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index');
}
function testMoshtariProfile() {
const row = {
CodeMelli: '00112312311',
Address: 'خیابان ولیعصر',
City: 'تهران',
Rasteh: 2,
BDate: null,
};
assert(pickNationalId(row) === '00112312311', 'national id');
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
assert(pickCity(row) === 'تهران', 'city');
assert(pickRasteh(row) === 2, 'rasteh');
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
const patch = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: { keep: true },
});
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
const fields = patch.customFields as Record<string, unknown>;
assert(fields.nationalId === '00112312311', 'cf national');
assert(fields.city === 'تهران', 'cf city');
assert(fields.rasteh === 2, 'cf rasteh');
assert(fields.keep === true, 'cf merge');
const emptyKeep = tahesabProfilePatch(
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
{
birthDate: new Date('2000-01-01'),
addresses: ['قدیمی'],
customFields: { nationalId: '111' },
},
);
assert(emptyKeep.addresses == null, 'empty does not wipe address');
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
assert(emptyKeep.birthDate == null, 'existing birth kept');
}
function main() {
testCryptoRoundtrip();
testFactorCode();
testMobile();
testMandehNormalize();
testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK');
}
+6
View File
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number;
}
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto {
@IsString()
connectionId!: string;
@@ -0,0 +1,113 @@
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../../common/excel';
export function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function trimStr(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === '-1') return null;
return s;
}
/** کد ملی از پاسخ لیست مشتری ته‌حساب */
export function pickNationalId(row: Record<string, unknown>): string | null {
return (
trimStr(row.CodeMelli) ??
trimStr(row.CodeMeli) ??
trimStr(row.codeMeli) ??
trimStr(row.NationalId) ??
null
);
}
export function pickAddress(row: Record<string, unknown>): string | null {
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
}
export function pickCity(row: Record<string, unknown>): string | null {
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
}
/** رسته حساب در ته‌حساب (عدد یا رشته) */
export function pickRasteh(row: Record<string, unknown>): string | number | null {
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
if (raw == null || raw === '') return null;
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (!s || s === '-1') return null;
const n = Number(s);
return Number.isFinite(n) && String(n) === s ? n : s;
}
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return {};
}
function asAddressList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => (typeof a === 'string' ? a.trim() : ''))
.filter(Boolean);
}
/**
* فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند.
* مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند.
*/
export function tahesabProfilePatch(
row: Record<string, unknown>,
existing: {
birthDate: Date | null;
addresses: unknown;
customFields: unknown;
},
): Prisma.PersonUpdateInput {
const data: Prisma.PersonUpdateInput = {};
const birthDate = pickBirthDate(row);
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
const address = pickAddress(row);
const city = pickCity(row);
if (address) {
const line = city ? `${address}، ${city}` : address;
data.addresses = [line] as Prisma.InputJsonValue;
} else if (city && asAddressList(existing.addresses).length === 0) {
data.addresses = [city] as Prisma.InputJsonValue;
}
const fields = asCustomFieldRecord(existing.customFields);
let fieldsChanged = false;
const nationalId = pickNationalId(row);
if (nationalId) {
fields.nationalId = nationalId;
fieldsChanged = true;
}
if (city) {
fields.city = city;
fieldsChanged = true;
}
if (address) {
fields.address = address;
fieldsChanged = true;
}
const rasteh = pickRasteh(row);
if (rasteh != null) {
fields.rasteh = rasteh;
fieldsChanged = true;
}
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
return data;
}
@@ -26,8 +26,9 @@ import {
SearchTahesabDto,
SyncGroupsPageDto,
} from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
}
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim());
@@ -438,6 +428,77 @@ export class TahesabPersonLinkService {
};
}
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi(
organizationId,
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
});
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const results = mapped.map((row) => {
const linked = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return {
...row,
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
: null,
};
});
const duplicates = await this.duplicateOwners(
organizationId,
dto.connectionId,
linked.filter((row) => !row.alreadyLinked),
);
const results = linked.map((row) => {
if (row.alreadyLinked || row.moshtariCode == null) return row;
const dup = duplicates.get(row.moshtariCode);
if (!dup) return row;
return {
...row,
duplicateOfCode: dup.moshtariCode,
duplicatePersonId: dup.personId,
duplicatePersonName: dup.personName,
};
});
await this.prisma.accountingSyncJob.create({
data: {
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
let preserveExistingLink = false;
if (personId) {
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing),
...tahesabProfilePatch(row, existing),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId: byMobile.id, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
return {
personId: byMobile.id,
link: this.linkDto(existing),
duplicateOf: linkedCode,
};
}
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
sourceRef,
...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
},
});
personId = byMobile.id;
} else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({
data: {
organizationId,
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab',
sourceRef,
birthDate: pickBirthDate(row),
birthDate: (profile.birthDate as Date | undefined) ?? null,
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
},
});
personId = created.id;
}
}
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
/** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(row, existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let duplicate = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
);
updated += 1;
} else {
await this.importFromTahesab(
const created = await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
imported += 1;
if (created.duplicateOf) duplicate += 1;
else imported += 1;
}
} catch (err) {
failed += 1;
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`;
const summary = [
`${imported} وارد شد`,
`${updated} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
const error = errors.length ? errors.join('؛ ') : null;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: summary,
customerSyncLastError: error,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
duplicate,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
error,
};
}
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
try {
const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
const row = rowsByCode.get(moshtariCode);
if (row) {
await this.refreshLinkedCustomer(
organizationId,
dto.connectionId,
linkedPersonId,
moshtariCode,
row,
);
}
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({
moshtariCode,
ok: true,
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
);
if (r.duplicateOf) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: r.personId,
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
});
continue;
}
items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) {
const message =
@@ -37,6 +37,7 @@ import {
SearchTahesabDto,
SearchTahesabProductsDto,
SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto,
UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto);
}
/** همهٔ مشتریان ته‌حساب: تازه‌ها وارد، لینک‌شده‌ها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) به‌روز */
@Post('sync/customers-now')
@Roles('owner', 'admin')
syncCustomersNow(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAllCustomersDto,
) {
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
}
@Get('sync/unlinked-people')
@Roles('owner', 'admin')
unlinkedPeople(
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { AiService } from '../../ai/ai.service';
import { NotesService } from '../../notes/notes.service';
import { PrismaService } from '../../prisma/prisma.service';
import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
import { SttClientService } from './stt-client.service';
const MAX_ATTEMPTS = 3;
@@ -17,6 +20,8 @@ export class CallSummaryService {
private readonly stt: SttClientService,
private readonly ai: AiService,
private readonly notes: NotesService,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {}
@Cron('*/1 * * * *')
@@ -74,7 +79,31 @@ export class CallSummaryService {
);
return;
}
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
if (daftId) {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: row.connectionId },
});
const token = conn
? await this.connections.decryptApiToken(conn)
: null;
if (!token) {
await this.markFailed(
id,
row.summaryAttempts,
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
true,
);
return;
}
const audio = await this.daftareshoma.downloadRecording(
token,
daftId,
);
transcript = await this.stt.transcribeAudio(audio);
} else {
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
}
await this.prisma.voipCallLog.update({
where: { id },
data: { transcript },
+159 -4
View File
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, '');
@@ -57,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {}
async listRecent(
organizationId: string,
connectionId?: string,
opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({
where: {
organizationId,
...(connectionId ? { connectionId } : {}),
...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
? { direction: opts.direction }
: {}),
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
...(opts?.lineExtension?.trim()
? { lineExtension: opts.lineExtension.trim() }
: {}),
},
orderBy: { syncedAt: 'desc' },
take: 50,
take: 100,
});
const personIds = rows
.map((r) => r.personId)
@@ -91,6 +109,7 @@ export class VoipCallsService {
status: r.status,
fromNumber: r.fromNumber,
toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId,
@@ -127,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || null;
const lineRaw = String(event.lineExtension ?? '').trim();
const lineExtension =
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId(
conn.organizationId,
@@ -193,6 +215,7 @@ export class VoipCallsService {
status,
fromNumber,
toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId,
durationSec,
@@ -213,6 +236,7 @@ export class VoipCallsService {
status,
fromNumber,
toNumber,
lineExtension,
personId,
activityId,
durationSec,
@@ -240,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber);
}
if (
conn.provider === 'daftareshoma' &&
['completed', 'answered', 'missed'].includes(status)
) {
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
this.logger.warn(
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
);
});
}
const link = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: {
@@ -261,10 +296,67 @@ export class VoipCallsService {
userId: string,
number: string,
personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> {
): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست');
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
provider: 'daftareshoma',
},
orderBy: { createdAt: 'asc' },
});
if (daftareshomaConn) {
const token = await this.connections.decryptApiToken(daftareshomaConn);
const fromNumber = daftareshomaConn.fromNumber?.trim();
const callerExtension = daftareshomaConn.callerExtension?.trim();
if (!token || !fromNumber || !callerExtension) {
throw new BadRequestException(
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
);
}
const toE164 = this.toPlusDigits(phone);
const fromE164 = this.toPlusDigits(fromNumber);
const extE164 = this.toPlusDigits(callerExtension);
try {
await this.daftareshoma.outgoingCall(token, {
fromNumber: fromE164,
toNumber: toE164,
callerExtension: extE164,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطا';
await this.prisma.voipConnection.update({
where: { id: daftareshomaConn.id },
data: { lastError: msg.slice(0, 400) },
});
throw new BadRequestException(msg);
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: daftareshomaConn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: fromE164,
toNumber: toE164,
personId: personId ?? null,
startedAt: new Date(),
},
});
return {
ok: true,
mode: 'daftareshoma',
dialUrl: `daftareshoma:${toE164}`,
};
}
const conn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
@@ -318,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
}
private toPlusDigits(raw: string): string {
const d = digitsOnly(raw);
if (!d) return raw.trim();
if (raw.trim().startsWith('+')) return `+${d}`;
if (d.startsWith('98')) return `+${d}`;
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
return `+${d}`;
}
private async enrichDaftaresomaRecording(
connectionId: string,
callId: string,
): Promise<void> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
});
if (!conn) return;
const log = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: { connectionId, externalCallId: callId },
},
});
if (!log) return;
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
if (
parseDaftaresomaRecordingRef(log.recordingUrl) &&
!['done', 'pending', 'processing'].includes(log.summaryStatus)
) {
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
summaryStatus: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
return;
}
const token = await this.connections.decryptApiToken(conn);
if (!token) return;
const row = await this.daftareshoma.findCallById(token, callId);
const recordingId = row?.recordingId?.trim();
if (!recordingId) return;
const ref = daftareshomaRecordingRef(recordingId);
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
recordingUrl: ref,
summaryStatus: ['done', 'pending', 'processing'].includes(
log.summaryStatus,
)
? log.summaryStatus
: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status);
}
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest,
UpdateVoipConnectionRequest,
VoipConnectionDto,
VoipProvider,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable()
export class VoipConnectionsService {
constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> {
const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است');
const provider = asProvider(dto.provider);
if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
}
const secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null;
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
const token = dto.apiToken?.trim();
const tokenEnc = token ? this.crypto.encrypt(token) : null;
const clickToCallUrl =
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
const row = await this.prisma.voipConnection.create({
data: {
organizationId,
label,
provider,
enabled: dto.enabled ?? true,
clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
},
});
return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
}
if (dto.fromNumber !== undefined) {
data.fromNumber = dto.fromNumber?.trim() || null;
}
if (dto.callerExtension !== undefined) {
data.callerExtension = dto.callerExtension?.trim() || null;
}
if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim();
if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag;
}
}
if (dto.apiToken !== undefined) {
const tok = dto.apiToken?.trim();
if (!tok) {
data.apiTokenCiphertext = null;
data.apiTokenIv = null;
data.apiTokenTag = null;
} else {
const enc = this.crypto.encrypt(tok);
data.apiTokenCiphertext = enc.ciphertext;
data.apiTokenIv = enc.iv;
data.apiTokenTag = enc.tag;
}
}
const row = await this.prisma.voipConnection.update({
where: { id: existing.id },
data,
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
});
}
async decryptApiToken(row: {
apiTokenCiphertext: string | null;
apiTokenIv: string | null;
apiTokenTag: string | null;
}): Promise<string | null> {
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.apiTokenCiphertext,
iv: row.apiTokenIv,
tag: row.apiTokenTag,
});
}
verifySignature(
rawBody: string | Buffer,
signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: {
id: string;
label: string;
provider?: string | null;
enabled: boolean;
webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null;
lastError: string | null;
createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return {
id: row.id,
label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError,
callCount,
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
export type DaftaresomaOutgoingCallInput = {
fromNumber: string;
toNumber: string;
callerExtension: string;
};
export type DaftaresomaCallSearchRow = {
id?: string;
fromNumber?: string;
toNumber?: string;
recordingId?: string | null;
direction?: string;
reportDispositionType?: string;
startTime?: string;
endTime?: string;
duration?: string;
extension?: string;
};
@Injectable()
export class DaftaresomaClient {
private readonly logger = new Logger(DaftaresomaClient.name);
async outgoingCall(
apiToken: string,
input: DaftaresomaOutgoingCallInput,
): Promise<void> {
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from_number: input.fromNumber,
to_number: input.toNumber,
caller_extension: input.callerExtension,
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
);
throw new Error(
text.trim()
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
);
}
}
async findCallById(
apiToken: string,
callId: string,
): Promise<DaftaresomaCallSearchRow | null> {
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
);
return null;
}
const json = (await res.json()) as {
data?: DaftaresomaCallSearchRow[];
};
const row = Array.isArray(json.data) ? json.data[0] : null;
return row ?? null;
}
async downloadRecording(
apiToken: string,
recordingId: string,
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
const url = new URL(`${BASE_URL}/GetCallRecording`);
url.searchParams.set('recordingId', recordingId);
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: '*/*',
'User-Agent': 'MontactCRM-VoIP/1.0',
},
signal: AbortSignal.timeout(60000),
redirect: 'follow',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
);
}
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
const buffer = await res.arrayBuffer();
if (buffer.byteLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
if (!buffer.byteLength) {
throw new Error('فایل ضبط خالی است');
}
const contentType =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
const filename = `daftareshoma-${recordingId}.wav`;
return { buffer, contentType, filename };
}
}
@@ -0,0 +1,168 @@
import type { VoipWebhookEvent } from '@mento/shared';
/** پیشوند ذخیره‌شده در recordingUrl برای واکشی بعدی با توکن API */
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
export function daftareshomaRecordingRef(recordingId: string): string {
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
}
export function parseDaftaresomaRecordingRef(
value: string | null | undefined,
): string | null {
const raw = String(value ?? '').trim();
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
return id || null;
}
type Loose = Record<string, unknown>;
function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
function pick(obj: Loose, ...keys: string[]): string {
for (const k of keys) {
const v = str(obj[k]);
if (v) return v;
}
return '';
}
function parseDurationSec(raw: unknown): number | undefined {
if (raw == null || raw === '') return undefined;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.max(0, Math.round(raw));
}
const s = String(raw).trim();
if (!s) return undefined;
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
const parts = s.split(':').map((p) => Number(p));
if (parts.some((n) => !Number.isFinite(n))) return undefined;
if (parts.length === 3) {
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
}
if (parts.length === 2) {
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
}
return undefined;
}
/**
* رویداد وب‌هوک دفتر شما را به VoipWebhookEvent تبدیل می‌کند.
* اگر payload از قبل فرمت Montact باشد، همان را برمی‌گرداند.
*/
export function mapDaftaresomaWebhook(
body: unknown,
): VoipWebhookEvent | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Loose;
const legacyCallId = pick(obj, 'callId');
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
return body as VoipWebhookEvent;
}
const eventType = pick(obj, 'EventType', 'eventType', 'event');
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
if (!callId) return null;
const caller = pick(
obj,
'CallerNumber',
'callerNumber',
'from',
'From',
'fromNumber',
);
/** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj,
'TransferredToNumber',
'transferredToNumber',
);
const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt =
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
undefined;
const durationSec = parseDurationSec(
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
);
const lower = eventType.toLowerCase();
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
const isStarted =
lower.includes('started') ||
lower.includes('ringing') ||
lower.includes('transferstarted');
const isEnded =
lower.includes('ended') ||
lower.includes('completed') ||
lower.includes('hangup');
let status: string = 'completed';
if (isStarted && !isEnded) {
status = 'ringing';
} else if (isEnded) {
const result = obj.Result ?? obj.result;
if (result === false || result === 'false' || result === 0) {
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
} else if (result === true || result === 'true' || result === 1) {
status = 'completed';
} else {
status = 'completed';
}
}
const recordingId = pick(
obj,
'RecordingId',
'recordingId',
'RecordId',
'recordId',
);
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
// outbound: from=همکار، to=مشتری (TransferredToNumber)
let from = caller || undefined;
let to: string | undefined;
if (isOutbound) {
to = transferred || undefined;
} else {
to = transferred || lineExtension || undefined;
}
const event: VoipWebhookEvent = {
callId,
direction: isOutbound ? 'outbound' : 'inbound',
status,
from,
to,
lineExtension,
startedAt,
endedAt,
durationSec,
caller: from,
callee: to,
};
if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId);
}
return event;
}
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const obj = body as Loose;
return Boolean(
pick(obj, 'EventType', 'eventType') ||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
);
}
+35
View File
@@ -1,5 +1,6 @@
import {
IsBoolean,
IsIn,
IsOptional,
IsString,
MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80)
label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional()
@IsString()
@MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500)
clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500)
clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
}
const timeoutMs = Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
);
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
return this.transcribeAudio(audio, timeoutMs);
}
async transcribeAudio(
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
timeoutMs?: number,
): Promise<string> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
}
const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
}
const timeoutMs = Number(
const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
);
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const timer = setTimeout(() => controller.abort(), waitMs);
try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto,
VoipClickToCallDto,
} from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('direction') direction?: string,
@Query('status') status?: string,
@Query('lineExtension') lineExtension?: string,
) {
return this.calls.listRecent(user.organizationId, connectionId);
return this.calls.listRecent(user.organizationId, {
connectionId,
direction,
status,
lineExtension,
});
}
@Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
}
if (!body?.callId) {
let event: VoipWebhookEvent | null = null;
if (
row.provider === 'daftareshoma' ||
isDaftaresomaWebhookPayload(body)
) {
event = mapDaftaresomaWebhook(body);
} else if (body?.callId) {
event = body;
}
if (!event?.callId) {
return { ok: false, error: 'callId required' };
}
const result = await this.calls.ingestWebhook(connectionId, body);
const result = await this.calls.ingestWebhook(connectionId, event);
return { ok: true, ...result };
}
}
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService,
VoipConnectionsService,
VoipCallsService,
DaftaresomaClient,
SttClientService,
CallSummaryService,
],
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string,
@Query('unassigned') unassigned?: string,
@Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) {
return this.leads.listIds(user, {
q,
@@ -258,7 +262,11 @@ export class LeadsController {
source,
unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
});
}
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string,
@Query('unassigned') unassigned?: string,
@Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) {
return this.leads.list(user, {
q,
@@ -291,8 +303,12 @@ export class LeadsController {
source,
unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort,
hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
});
}
+95 -17
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string;
unassigned?: boolean;
archived?: boolean;
converted?: boolean;
hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
},
): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,22 +171,77 @@ export class LeadsService {
if (opts.hasMobile) {
and.push({ mobile: { not: '' } });
}
const follow = this.followUpWhere(opts.followUp);
if (follow) and.push(follow);
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
if (createdRange) and.push(createdRange);
return {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
},
...(opts.unassigned ? [{ assigneeId: null }] : []),
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
...and,
],
};
}
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
private tehranDayBounds(day = tehranDayKey()) {
const start = tehranDayStart(day);
const end = tehranDayStart(addDays(day, 1));
return { start, end };
}
private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null {
if (!followUp) return null;
const { start, end } = this.tehranDayBounds();
switch (followUp) {
case 'due_today':
return { nextFollowUpAt: { gte: start, lt: end } };
case 'overdue':
return { nextFollowUpAt: { not: null, lt: start } };
case 'called_today':
return { lastContactAt: { gte: start, lt: end } };
case 'missed_today':
return {
nextFollowUpAt: { not: null, lt: end },
OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }],
};
case 'no_follow_up':
return { nextFollowUpAt: null };
default:
return null;
}
}
private createdAtWhere(
createdFrom?: string,
createdTo?: string,
): Prisma.LeadWhereInput | null {
const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null;
const to = isDayKey(createdTo)
? tehranDayStart(addDays(createdTo, 1))
: null;
if (!from && !to) return null;
return {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
};
}
@@ -194,8 +259,12 @@ export class LeadsService {
source?: string;
unassigned?: boolean;
archived?: boolean;
converted?: boolean;
sort?: string;
hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
},
): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId);
@@ -243,7 +312,11 @@ export class LeadsService {
source?: string;
unassigned?: boolean;
archived?: boolean;
converted?: boolean;
hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
@@ -1116,7 +1189,7 @@ export class LeadsService {
newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length
? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } },
@@ -1499,11 +1572,16 @@ export class LeadsService {
const mobile = lead.mobile || lead.phone;
if (!mobile) throw new BadRequestException('برای این لید شماره موبایل ثبت نشده است.');
const name = (lead.contactName || lead.title).trim();
const org = await this.prisma.organization.findUnique({
where: { id: actor.organizationId },
select: { name: true },
});
const message = renderSmsText(body, {
name,
phone: mobile,
title: lead.title,
company: lead.contactName ? lead.title : '',
company: lead.trade?.trim() || '',
from: org?.name?.trim() || '',
});
if (!message) throw new BadRequestException('متن پیامک خالی است.');
const result = await this.sms.send({
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
@@ -70,6 +71,16 @@ export class MembershipsService {
}));
}
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
async assertSoleOwner(organizationId: string, userId: string) {
const others = await this.countActiveOwners(organizationId, userId);
if (others > 0) {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
}
async createMembership(data: {
userId: string;
organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number;
isActive?: boolean;
}) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({
data: {
userId: data.userId,
@@ -56,7 +56,19 @@ function testTemplate() {
}) === 'سلام علی 0912',
'name phone vars',
);
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
assert(
applyMessageTemplate('تبریک {از طرف}', { from: 'هنراندیشه' }) ===
'تبریک هنراندیشه',
'from org var',
);
assert(
applyMessageTemplate('مانده {مانده ریالی} / {مانده فلزی}', {
rialBalance: '۱۰۰۰ بس',
metalBalance: 'طلا ۲',
}) === 'مانده ۱۰۰۰ بس / طلا ۲',
'balance vars',
);
assert(clipSms('ا'.repeat(200)).length === 200, 'sms is not clipped');
}
testDelay();
@@ -77,6 +77,17 @@ export function buildSendAts(
return times;
}
/** یکسان‌سازی کلید توکن‌های قالب (فاصله/ZWNJ/ی عربی). */
export function normalizeTemplateKey(key: string): string {
return String(key ?? '')
.normalize('NFKC')
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
}
export function applyMessageTemplate(
text: string,
vars: {
@@ -85,22 +96,44 @@ export function applyMessageTemplate(
phone?: string;
date?: string;
company?: string;
/** نام سازمان فرستنده */
from?: string;
/** مانده مالی قالب‌بندی‌شده */
rialBalance?: string;
/** مانده‌های وزنی/فلزی قالب‌بندی‌شده */
metalBalance?: string;
},
): string {
const map: Record<string, string> = {
title: vars.title ?? '',
عنوان: vars.title ?? '',
نام: vars.name ?? '',
name: vars.name ?? '',
شماره: vars.phone ?? '',
phone: vars.phone ?? '',
تاریخ: vars.date ?? '',
date: vars.date ?? '',
شرکت: vars.company ?? '',
company: vars.company ?? '',
};
const from = vars.from ?? '';
const rial = vars.rialBalance ?? '';
const metal = vars.metalBalance ?? '';
const entries: Array<[string, string]> = [
['title', vars.title ?? ''],
['عنوان', vars.title ?? ''],
['نام', vars.name ?? ''],
['name', vars.name ?? ''],
['شماره', vars.phone ?? ''],
['phone', vars.phone ?? ''],
['تاریخ', vars.date ?? ''],
['date', vars.date ?? ''],
['شرکت', vars.company ?? ''],
['company', vars.company ?? ''],
['از طرف', from],
['ازطرف', from],
['from', from],
['مانده ریالی', rial],
['ماندهریالی', rial],
['rialBalance', rial],
['مانده فلزی', metal],
['ماندهفلزی', metal],
['metalBalance', metal],
];
const map: Record<string, string> = {};
for (const [key, value] of entries) {
map[normalizeTemplateKey(key)] = value;
}
return text.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()];
const next = map[normalizeTemplateKey(key)];
return next === undefined ? full : next;
});
}
@@ -356,6 +356,10 @@ export class MessageAutomationsService {
})
: null;
const lead = await this.lookupLead(job);
const org = await this.prisma.organization.findUnique({
where: { id: job.organizationId },
select: { name: true },
});
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
@@ -366,6 +370,7 @@ export class MessageAutomationsService {
phone,
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
from: org?.name?.trim() || '',
};
const title = applyMessageTemplate(job.rule.title, vars).slice(0, 200);
const body = applyMessageTemplate(job.rule.body, vars);
@@ -1,9 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { OutboundSmsLogService } from './outbound-sms-log.service';
import { SmsTemplatesController } from './sms-templates.controller';
import { SmsTemplatesService } from './sms-templates.service';
@Global()
@Module({
providers: [OutboundSmsLogService],
exports: [OutboundSmsLogService],
controllers: [SmsTemplatesController],
providers: [OutboundSmsLogService, SmsTemplatesService],
exports: [OutboundSmsLogService, SmsTemplatesService],
})
export class OutboundSmsModule {}
@@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
import { SmsTemplatesService } from './sms-templates.service';
@Controller('sms-templates')
@UseGuards(JwtAuthGuard)
export class SmsTemplatesController {
constructor(private readonly templates: SmsTemplatesService) {}
@Get()
list(@CurrentUser() user: Actor) {
return this.templates.list(user.organizationId);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateSmsTemplateDto) {
return this.templates.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateSmsTemplateDto,
) {
return this.templates.update(user.organizationId, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.templates.remove(user.organizationId, id);
}
}
@@ -0,0 +1,28 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { SMS_TEMPLATE_BODY_MAX, SMS_TEMPLATE_NAME_MAX } from '@mento/shared';
export class CreateSmsTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name!: string;
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body!: string;
}
export class UpdateSmsTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body?: string;
}
@@ -0,0 +1,92 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Prisma } from '@prisma/client';
import {
parseSmsTemplates,
SMS_TEMPLATE_MAX,
type SmsTemplateDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
@Injectable()
export class SmsTemplatesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<SmsTemplateDto[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { smsTemplates: true },
});
return parseSmsTemplates(row?.smsTemplates);
}
async create(
organizationId: string,
dto: CreateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (current.length >= SMS_TEMPLATE_MAX) {
throw new BadRequestException(
`حداکثر ${SMS_TEMPLATE_MAX} پیامک آماده می‌توانید داشته باشید.`,
);
}
const next: SmsTemplateDto[] = [
...current,
{
id: randomUUID(),
name: dto.name.trim(),
body: dto.body.trim(),
},
];
return this.persist(organizationId, next);
}
async update(
organizationId: string,
id: string,
dto: UpdateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
const idx = current.findIndex((item) => item.id === id);
if (idx < 0) throw new NotFoundException('پیامک آماده پیدا نشد.');
const prev = current[idx]!;
const next = [...current];
next[idx] = {
id: prev.id,
name: dto.name !== undefined ? dto.name.trim() : prev.name,
body: dto.body !== undefined ? dto.body.trim() : prev.body,
};
return this.persist(organizationId, next);
}
async remove(organizationId: string, id: string): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (!current.some((item) => item.id === id)) {
throw new NotFoundException('پیامک آماده پیدا نشد.');
}
return this.persist(
organizationId,
current.filter((item) => item.id !== id),
);
}
private async persist(
organizationId: string,
items: SmsTemplateDto[],
): Promise<SmsTemplateDto[]> {
const stored = parseSmsTemplates(items);
await this.prisma.organization.update({
where: { id: organizationId },
data: { smsTemplates: stored as unknown as Prisma.InputJsonValue },
});
return stored;
}
}
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.pipelinesService.list(user.organizationId);
list(
@CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
}
@Get('board')
+154 -9
View File
@@ -4,6 +4,10 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto,
MergePipelineResultDto,
PipelineBoardDto,
@@ -102,24 +106,55 @@ export class PipelinesService {
return this.toPipelineDto(created);
}
async list(organizationId: string): Promise<PipelineDto[]> {
async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
const organizationId =
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
const counts = await this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId },
_count: { _all: true },
});
const lens = actor?.priorLens;
const lensActive = forLens && (lens === 'current' || lens === 'archive');
const access = actor ? dealAccessWhere(actor) : {};
const prior = lensActive
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
: {};
const [counts, lensCounts] = await Promise.all([
this.prisma.deal.groupBy({
by: ['pipelineId'],
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
_count: { _all: true },
}),
lensActive
? this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId, AND: [access, prior] },
_count: { _all: true },
})
: Promise.resolve([]),
]);
const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]),
);
return rows.map((row) => ({
const lensCountByPipeline = new Map(
lensCounts.map((row) => [row.pipelineId, row._count._all]),
);
const visible = lensActive
? rows.filter((row) => {
const inLens = lensCountByPipeline.get(row.id) ?? 0;
const total = dealCountByPipeline.get(row.id) ?? 0;
if (lens === 'archive') return inLens > 0;
return inLens > 0 || total === 0;
})
: rows;
return visible.map((row) => ({
...this.toPipelineDto(row),
dealCount: dealCountByPipeline.get(row.id) ?? 0,
dealCount: lensActive
? (lensCountByPipeline.get(row.id) ?? 0)
: (dealCountByPipeline.get(row.id) ?? 0),
}));
}
@@ -343,12 +378,23 @@ export class PipelinesService {
});
const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) {
const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal));
if (list) {
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
}
return {
@@ -566,6 +612,105 @@ export class PipelinesService {
};
}
private async loadDealActivityHints(
organizationId: string,
dealIds: string[],
): Promise<
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
> {
const hints = new Map<
string,
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
>();
if (!dealIds.length) return hints;
const rows = await this.prisma.activity.findMany({
where: {
organizationId,
dealId: { in: dealIds },
status: { in: ['open', 'done'] },
},
select: {
id: true,
dealId: true,
title: true,
type: true,
status: true,
dueAt: true,
completedAt: true,
},
});
type Row = {
id: string;
title: string;
type: string;
status: 'open' | 'done';
dueAt: Date | null;
completedAt: Date | null;
};
const byDeal = new Map<string, Row[]>();
for (const row of rows) {
if (!row.dealId) continue;
const list = byDeal.get(row.dealId) ?? [];
list.push({
id: row.id,
title: row.title,
type: row.type,
status: row.status === 'done' ? 'done' : 'open',
dueAt: row.dueAt,
completedAt: row.completedAt,
});
byDeal.set(row.dealId, list);
}
const now = Date.now();
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
if (item.status === 'done') return 'done';
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
return 'upcoming';
};
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
for (const dealId of dealIds) {
const list = byDeal.get(dealId) ?? [];
const summaries = list
.map((item) => {
const cue = cueOf(item);
return {
id: item.id,
title: item.title,
type: item.type as ActivityType,
status: item.status as ActivityStatus,
dueAt: item.dueAt?.toISOString() ?? null,
cue,
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
_done: item.completedAt?.getTime() ?? 0,
};
})
.sort((a, b) => {
const byRank = rank(a.cue) - rank(b.cue);
if (byRank) return byRank;
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
return a._due - b._due;
})
.map(({ _due: _d, _done: _c, ...summary }) => summary);
const open = list.filter((item) => item.status === 'open');
let cue: DealActivityCue = 'none';
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
cue = 'overdue';
} else if (open.length) {
cue = 'upcoming';
} else if (list.some((item) => item.status === 'done')) {
cue = 'done';
}
hints.set(dealId, { cue, summaries });
}
return hints;
}
toDealDto(row: DealRow): DealDto {
return {
id: row.id,
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared';
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null;
}
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto,
SetUserCapDto,
CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers);
}
@Post('organizations/:id/product-plan')
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
return this.platform.applyProductPlan(id, dto.planCode);
}
@Patch('organizations/:id/modules')
updateOrganizationModules(
@Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
ProductPlanCode,
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId);
}
async applyProductPlan(id: string, planCode: ProductPlanCode) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.applyProductPlan(id, planCode);
return this.getOrganization(id);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({
where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
}
if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (role === 'owner' && row.role !== 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners(
row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date();
const { jy, jm } = dateToJalali(now);
+2
View File
@@ -41,6 +41,7 @@ export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{
export const routineInclude = {
category: true,
assignee: userSelect,
createdBy: userSelect,
team: { select: { id: true, name: true } },
} satisfies Prisma.RoutineInclude;
@@ -186,6 +187,7 @@ export function toRoutineDto(
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
createdById: row.createdById,
createdBy: row.createdBy,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
@@ -273,6 +273,7 @@ export class RoutinesService {
category: _cat,
assignee: _a,
team: _t,
createdBy: _cb,
deletedAt: _d,
...rest
} = src;
@@ -511,7 +511,7 @@ export class SmsCampaignsService {
const leadIds = batch
.map((r) => r.leadId)
.filter((id): id is string => Boolean(id));
const [people, leads] = await Promise.all([
const [people, leads, org] = await Promise.all([
personIds.length
? this.prisma.person.findMany({
where: { id: { in: personIds } },
@@ -535,9 +535,14 @@ export class SmsCampaignsService {
},
})
: Promise.resolve([]),
this.prisma.organization.findUnique({
where: { id: campaign.organizationId },
select: { name: true },
}),
]);
const personMap = new Map(people.map((p) => [p.id, p]));
const leadMap = new Map(leads.map((l) => [l.id, l]));
const fromOrg = org?.name?.trim() || '';
let sent = 0;
let failed = 0;
@@ -572,6 +577,7 @@ export class SmsCampaignsService {
phone: recipient.mobile,
title,
company,
from: fromOrg,
});
const personalized = await this.tracking.personalizeSmsBody({
+212
View File
@@ -0,0 +1,212 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
export type InvoicePartyPosition = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
/** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */
billed: Prisma.Decimal;
/** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */
offset: Prisma.Decimal;
/** مثبت = طلب ما، منفی = بدهی ما */
net: Prisma.Decimal;
};
type Bucket = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
sales: Prisma.Decimal;
credits: Prisma.Decimal;
paid: Prisma.Decimal;
opening: Prisma.Decimal;
};
function zero() {
return new Prisma.Decimal(0);
}
function round2(value: Prisma.Decimal) {
return value.toDecimalPlaces(2);
}
/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */
export function signedInvoicePayable(invoice: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}) {
const subtotal = invoice.lineItems.reduce(
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
zero(),
);
const discount = invoice.discountAmount ?? zero();
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
const signed = round2(subtotal.minus(discount).plus(tax));
return invoice.kind === 'credit' ? signed.neg() : signed;
}
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
const connections = await prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return connections === 0;
}
/**
* ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره.
* سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود.
* اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد.
*/
export async function loadInvoicePartyPositions(
prisma: PrismaService,
organizationId: string,
): Promise<InvoicePartyPosition[]> {
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
const [invoices, openings] = await Promise.all([
prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
personId: true,
companyId: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
lineItems: { select: { quantity: true, unitPrice: true } },
payments: { select: { amount: true } },
},
}),
prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: {
personId: true,
amount: true,
person: { select: { firstName: true, lastName: true } },
},
}),
]);
const buckets = new Map<string, Bucket>();
const ensure = (
key: string,
partyKind: 'person' | 'company',
partyName: string,
) => {
const current = buckets.get(key);
if (current) return current;
const created: Bucket = {
key,
partyKind,
partyName,
sales: zero(),
credits: zero(),
paid: zero(),
opening: zero(),
};
buckets.set(key, created);
return created;
};
for (const invoice of invoices) {
const personId = invoice.personId;
const companyId = invoice.companyId;
const party = personId
? {
key: `person:${personId}`,
partyKind: 'person' as const,
partyName:
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
'مشتری',
}
: companyId
? {
key: `company:${companyId}`,
partyKind: 'company' as const,
partyName: invoice.company?.name?.trim() || 'شرکت',
}
: null;
if (!party) continue;
const bucket = ensure(party.key, party.partyKind, party.partyName);
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
bucket.paid = bucket.paid.plus(paid);
}
for (const opening of openings) {
if (opening.amount.equals(0)) continue;
const bucket = ensure(
`person:${opening.personId}`,
'person',
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
);
bucket.opening = bucket.opening.plus(opening.amount);
}
const positions: InvoicePartyPosition[] = [];
for (const bucket of buckets.values()) {
const net = round2(
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
);
if (net.equals(0)) continue;
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
const offset = round2(billed.minus(net));
positions.push({
key: bucket.key,
partyKind: bucket.partyKind,
partyName: bucket.partyName,
billed,
offset: Prisma.Decimal.max(offset, 0),
net,
});
}
return positions;
}
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */
export async function invoiceSalesInPeriod(
prisma: PrismaService,
organizationId: string,
from: Date | null,
to: Date | null,
) {
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
const invoices = await prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
issuedAt: true,
createdAt: true,
lineItems: { select: { quantity: true, unitPrice: true } },
},
});
let sales = zero();
let credits = zero();
for (const invoice of invoices) {
const when = invoice.issuedAt ?? invoice.createdAt;
if (from && when < from) continue;
if (to && when > to) continue;
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) sales = sales.plus(payable);
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
}
return {
sales: round2(sales).toFixed(2),
credits: round2(credits).toFixed(2),
net: round2(sales.minus(credits)).toFixed(2),
};
}
+20 -10
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true;
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const permissions = permissionsForTreasury(
const permissions = permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || '';
if (
isLookupForDocuments(method, url) &&
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
) {
return true;
}
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
}
}
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 isLookupForDocuments(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
+180 -6
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0);
@@ -12,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2);
}
function reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
function periodKey(date: Date) {
return date.toISOString().slice(0, 10);
}
@@ -36,9 +46,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId },
include: { movements: true },
include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' },
take: 100,
take: 500,
});
const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } },
@@ -55,8 +71,11 @@ export class TreasuryOpsService {
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string;
notes?: string | null;
},
@@ -81,14 +100,23 @@ export class TreasuryOpsService {
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
include: { movements: true },
include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
});
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? '');
@@ -124,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
const status = payrollStatus(row.payable, paid.plus(amount));
await this.prisma.$transaction([
this.prisma.financialMovement.create({
data: {
@@ -145,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor);
}
async updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId },
@@ -327,6 +472,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +558,15 @@ export class TreasuryOpsService {
amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
}));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return {
bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn),
cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)),
@@ -475,12 +627,22 @@ export class TreasuryOpsService {
period: string;
baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal;
dueDate: Date;
status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | null;
}>;
},
fullName: string,
) {
@@ -493,13 +655,25 @@ export class TreasuryOpsService {
period: row.period,
baseAmount: money(row.baseAmount),
benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable),
paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(),
status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '',
})),
};
}
@@ -129,6 +129,29 @@ export class TreasuryController {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Post('party-balances/collect')
collectPartyBalance(
@CurrentUser() user: Actor,
@Body()
body: {
kind?: string;
partyKind?: 'person' | 'company' | 'user';
partyId?: string;
amount?: string | number;
accountId?: string;
occurredAt?: string;
},
) {
return this.treasury.collectPartyBalance(user, {
kind: body.kind === 'expense' ? 'expense' : 'income',
partyKind: body.partyKind ?? 'person',
partyId: body.partyId ?? '',
amount: body.amount ?? 0,
accountId: body.accountId ?? '',
occurredAt: body.occurredAt,
});
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
@@ -158,6 +181,33 @@ export class TreasuryController {
});
}
@Get('documents/:id')
getDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.getDocument(user, id);
}
@Patch('documents/:id')
updateDocument(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body()
body: {
title?: string;
notes?: string | null;
categoryId?: string | null;
method?: string | null;
bookedAt?: string;
amount?: string | number;
},
) {
return this.treasury.updateDocument(user, id, body);
}
@Delete('documents/:id')
deleteDocument(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteDocument(user, id);
}
@Get('attachments/:id')
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
const file = await this.treasury.openAttachment(user, id);
@@ -232,6 +282,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body);
}
@Patch('payroll/:id')
updatePayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
) {
return this.ops.updatePayroll(user, id, body);
}
@Patch('payroll/:id/payments/:movementId')
updatePayrollPayment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('movementId') movementId: string,
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
) {
return this.ops.updatePayrollPayment(user, id, movementId, body);
}
@Post('payroll/:id/pay')
payPayroll(
@CurrentUser() user: Actor,
+441 -24
View File
@@ -4,7 +4,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { parseUserTreasuryAccess } from '@mento/shared';
import {
canManageTreasuryDocuments,
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import ExcelJS from 'exceljs';
@@ -25,6 +29,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -359,6 +364,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
...(from || to
? {
occurredAt: {
@@ -377,6 +383,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from },
},
select: { direction: true, amount: true },
@@ -503,13 +510,87 @@ export class TreasuryService {
}
: {}),
},
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
include: {
category: true,
account: true,
movements: true,
attachments: { select: { id: true, fileName: true, mimeType: true } },
},
orderBy: { bookedAt: 'desc' },
take: 200,
});
return rows.map((row) => this.toDocumentDto(row));
}
async getDocument(actor: Actor, id: string): Promise<FinancialDocumentDto> {
const doc = await this.requireScopedDocument(actor, id);
return this.toDocumentDto(doc);
}
async updateDocument(
actor: Actor,
id: string,
input: {
title?: string;
notes?: string | null;
categoryId?: string | null;
method?: string | null;
bookedAt?: string;
amount?: string | number;
},
): Promise<FinancialDocumentDto> {
await this.assertDocumentsManage(actor);
const doc = await this.requireScopedDocument(actor, id);
if (doc.status === 'void' || doc.voidedAt) {
throw new BadRequestException('سند باطل‌شده قابل ویرایش نیست.');
}
const data: Prisma.FinancialDocumentUncheckedUpdateInput = {};
if (input.title !== undefined) {
const title = input.title.trim();
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
data.title = title;
}
if (input.notes !== undefined) data.notes = input.notes?.trim() || null;
if (input.categoryId !== undefined) data.categoryId = input.categoryId || null;
if (input.method !== undefined) data.method = input.method || null;
if (input.bookedAt !== undefined) {
const bookedAt = startOfDay(input.bookedAt);
if (!bookedAt) throw new BadRequestException('تاریخ نامعتبر است.');
data.bookedAt = bookedAt;
}
if (input.amount !== undefined) {
const active = doc.movements.filter((row) => !row.voidedAt);
if (active.length > 0 || doc.status !== 'open') {
throw new BadRequestException('مبلغ فقط برای سند باز بدون دریافت/پرداخت قابل تغییر است.');
}
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
data.amount = amount;
}
await this.prisma.financialDocument.update({ where: { id: doc.id }, data });
return this.getDocument(actor, id);
}
async deleteDocument(actor: Actor, id: string): Promise<{ ok: true }> {
await this.assertDocumentsManage(actor);
const doc = await this.requireScopedDocument(actor, id);
const active = doc.movements.filter((row) => !row.voidedAt);
if (doc.status !== 'void' && active.length > 0) {
throw new BadRequestException('ابتدا سند را ابطال کنید، سپس حذف کنید.');
}
const attachments = await this.prisma.financialAttachment.findMany({
where: { documentId: doc.id, organizationId: actor.organizationId },
select: { id: true, storageKey: true },
});
for (const file of attachments) {
const bytes = await this.files.sizeOf(file.storageKey);
await this.files.remove(file.storageKey);
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
}
await this.prisma.financialDocument.delete({ where: { id: doc.id } });
return { ok: true };
}
async createDocument(
actor: Actor,
input: {
@@ -530,6 +611,8 @@ export class TreasuryService {
settleAmount?: string | number | null;
accountId?: string | null;
settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
},
): Promise<FinancialDocumentDto> {
const title = input.title.trim();
@@ -552,14 +635,19 @@ export class TreasuryService {
if (input.accountId) {
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (input.kind === 'expense' && settle.greaterThan(0)) {
const balance = await this.accountBalance(account.id);
if (balance.lessThan(settle)) {
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
}
}
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const settlement = input.settlement === true;
if (settlement) {
if (party.partyKind === 'other') {
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
}
if (!settle.equals(amount) || !settle.greaterThan(0)) {
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
}
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
}
const payExisting = settlement;
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
@@ -606,6 +694,45 @@ export class TreasuryService {
},
});
}
if (payExisting && input.accountId) {
const leftover = await this.allocatePartyBalance(tx, actor, {
party,
sourceId: created.id,
kind: input.kind,
amount: settle,
accountId: input.accountId,
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
});
if (leftover.greaterThan(0)) {
const opposite = input.kind === 'income' ? 'expense' : 'income';
const oppositeRef = await this.nextDocumentReference(
tx,
actor.organizationId,
opposite,
input.bookedAt,
bookedAt,
);
await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
kind: opposite,
title,
amount: leftover,
bookedAt,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
status: 'open',
reference: oppositeRef,
notes: `source:${created.id}`,
createdById: actor.userId,
},
});
}
}
return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id },
include: { category: true, account: true, movements: true },
@@ -727,6 +854,7 @@ export class TreasuryService {
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
@@ -747,13 +875,43 @@ export class TreasuryService {
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current);
}
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
const onDebtorSide = party.net.greaterThan(0);
if (kind === 'income' && !onDebtorSide) continue;
if (kind === 'expense' && onDebtorSide) continue;
const remaining = party.net.abs();
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
const current = buckets.get(party.key) ?? {
key: party.key,
partyKind: party.partyKind,
partyName: party.partyName,
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(remaining);
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
current.partyName = party.partyName;
}
buckets.set(party.key, current);
}
return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
@@ -762,9 +920,116 @@ export class TreasuryService {
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
documentRemaining: money(row.documentRemaining),
}));
}
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
async collectPartyBalance(
actor: Actor,
input: {
kind: 'income' | 'expense';
partyKind: 'person' | 'company' | 'user';
partyId: string;
amount: string | number;
accountId: string;
occurredAt?: string;
},
): Promise<{ applied: string; documentCount: number }> {
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const partyId = input.partyId.trim();
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
}
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
const scope = await this.accountScope(actor);
const docs = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: input.kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.partyKind,
...(input.partyKind === 'person' ? { personId: partyId } : {}),
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
...(input.partyKind === 'user' ? { userId: partyId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const open = docs
.map((doc) => {
const remaining = doc.amount.minus(this.settledOf(doc.movements));
return { doc, remaining };
})
.filter((row) => row.remaining.greaterThan(0));
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
if (!available.greaterThan(0)) {
throw new BadRequestException(
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
);
}
if (amount.greaterThan(available)) {
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
}
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
let left = amount;
const touched: string[] = [];
await this.prisma.$transaction(async (tx) => {
for (const row of open) {
if (!left.greaterThan(0)) break;
const slice = left.lessThan(row.remaining) ? left : row.remaining;
const status = slice.equals(row.remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
documentId: row.doc.id,
direction: input.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt,
memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده',
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.doc.id },
data: { status, accountId: account.id },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.doc.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
touched.push(row.doc.id);
left = left.minus(slice);
}
});
await this.prisma.financialAuditLog.create({
data: {
organizationId: actor.organizationId,
entityType: 'document',
entityId: touched[0] ?? partyId,
action: 'settle',
after: {
amount: amount.toFixed(2),
accountId: account.id,
documentIds: touched,
partyId,
},
actorUserId: actor.userId,
},
}).catch(() => undefined);
return { applied: money(amount), documentCount: touched.length };
}
private async resolveParty(
actor: Actor,
input: {
@@ -851,12 +1116,6 @@ export class TreasuryService {
}
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
if (doc.kind === 'expense') {
const balance = await this.accountBalance(account.id);
if (balance.lessThan(amount)) {
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
}
}
const nextSettled = settled.plus(amount);
const status = nextSettled.equals(doc.amount) ? 'settled' : 'partial';
await this.prisma.$transaction([
@@ -908,16 +1167,51 @@ export class TreasuryService {
});
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date();
await this.prisma.$transaction([
this.prisma.financialMovement.updateMany({
await this.prisma.$transaction(async (tx) => {
const allocations = await tx.financialMovement.findMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
select: { documentId: true },
});
await tx.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now },
}),
this.prisma.financialDocument.update({
});
await tx.financialMovement.updateMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
data: { voidedAt: now },
});
await tx.financialDocument.update({
where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}),
]);
});
const excess = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
notes: `source:${doc.id}`,
voidedAt: null,
status: { in: ['open', 'partial'] },
},
include: { movements: { select: { voidedAt: true } } },
});
for (const child of excess) {
if (child.movements.some((row) => !row.voidedAt)) continue;
await tx.financialDocument.update({
where: { id: child.id },
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
});
}
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
for (const documentId of affected) {
const row = await tx.financialDocument.findUnique({
where: { id: documentId },
include: { movements: { select: { amount: true, voidedAt: true } } },
});
if (!row || row.voidedAt || row.status === 'void') continue;
const settled = this.settledOf(row.movements);
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
}
});
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id },
include: { category: true, account: true, movements: true },
@@ -1092,6 +1386,17 @@ export class TreasuryService {
}
const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._sum.amount);
const invoiceSales = await invoiceSalesInPeriod(
this.prisma,
actor.organizationId,
from,
to,
);
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
else payables = payables.plus(party.net.abs());
}
const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0),
@@ -1101,7 +1406,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables),
payables: money(payables),
totalBalance: money(totalBalance),
@@ -1111,6 +1416,76 @@ export class TreasuryService {
};
}
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
private async allocatePartyBalance(
tx: Prisma.TransactionClient,
actor: Actor,
input: {
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
sourceId: string;
kind: 'income' | 'expense';
amount: Prisma.Decimal;
accountId: string;
occurredAt: Date;
memo: string;
},
): Promise<Prisma.Decimal> {
const scope = await this.accountScope(actor);
const openDocs = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
id: { not: input.sourceId },
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.party.partyKind,
...(input.party.personId ? { personId: input.party.personId } : {}),
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
...(input.party.userId ? { userId: input.party.userId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const ordered = [
...openDocs.filter((row) => row.kind === input.kind),
...openDocs.filter((row) => row.kind !== input.kind),
];
let left = input.amount;
for (const row of ordered) {
if (!left.greaterThan(0)) break;
const remaining = row.amount.minus(this.settledOf(row.movements));
if (!remaining.greaterThan(0)) continue;
const slice = left.lessThan(remaining) ? left : remaining;
const status = slice.equals(remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
documentId: row.id,
direction: row.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: 'allocation',
occurredAt: input.occurredAt,
memo: input.memo,
reference: `alloc:${input.sourceId}`,
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.id },
data: { status },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
left = left.minus(slice);
}
return left;
}
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements
.filter((row) => !row.voidedAt)
@@ -1119,7 +1494,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null },
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true },
});
return rows.reduce(
@@ -1128,6 +1503,47 @@ export class TreasuryService {
);
}
private async actorPermissions(actor: Actor): Promise<string[]> {
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: {
orgRole: { select: { permissions: true } },
treasuryAccessOverride: true,
},
});
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
return permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
}
private async assertDocumentsManage(actor: Actor) {
if (canManageTreasuryDocuments(actor.role, await this.actorPermissions(actor))) return;
throw new ForbiddenException('اجازه ویرایش یا حذف اسناد را ندارید.');
}
private async requireScopedDocument(actor: Actor, id: string) {
const scope = await this.accountScope(actor);
const doc = await this.prisma.financialDocument.findFirst({
where: {
id,
organizationId: actor.organizationId,
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: {
category: true,
account: true,
movements: true,
attachments: { select: { id: true, fileName: true, mimeType: true } },
},
});
if (!doc) throw new NotFoundException('سند پیدا نشد.');
return doc;
}
private async accountScope(actor: Actor): Promise<string[] | null> {
if (actor.role === 'owner' || actor.role === 'admin') return null;
const membership = await this.prisma.membership.findFirst({
@@ -1194,7 +1610,7 @@ export class TreasuryService {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null },
where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true, kind: true, occurredAt: true },
});
let balance = new Prisma.Decimal(0);
@@ -1251,7 +1667,8 @@ export class TreasuryService {
category?: { name: string } | null;
account?: { name: string } | null;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
attachments?: Array<{ id: string; fileName: string }>;
attachments?: Array<{ id: string; fileName: string; mimeType?: string }>;
voidedAt?: Date | null;
}): FinancialDocumentDto {
const settled = this.settledOf(row.movements);
const rawRemaining = row.amount.minus(settled);
+15 -3
View File
@@ -104,8 +104,10 @@ export class UsersService {
orgRoleId: dto.orgRoleId,
role: dto.role,
});
if (resolved.role === 'owner' && actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
if (resolved.role === 'owner') {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
@@ -449,6 +451,10 @@ export class UsersService {
}
}
if (nextRole === 'owner' && target.role !== 'owner') {
await this.memberships.assertSoleOwner(organizationId, target.userId);
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners(
organizationId,
@@ -456,7 +462,7 @@ export class UsersService {
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
@@ -528,6 +534,9 @@ export class UsersService {
}
}
if (isActive && !target.isActive) {
if (target.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, targetId);
}
await this.billing.assertSeatAvailable(organizationId, targetId);
}
await this.prisma.membership.update({
@@ -558,6 +567,9 @@ export class UsersService {
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId);
if (resolved.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, userId);
}
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
+4
View File
@@ -38,6 +38,10 @@ describe('اتوماسیون پیام', () => {
'سلام علی از منتکت - {unknown}',
);
assert.equal(applyMessageTemplate('{ name }', { name: 'x' }), 'x');
assert.equal(
applyMessageTemplate('از طرف {از طرف}', { from: 'هنراندیشه' }),
'از طرف هنراندیشه',
);
});
it('متن پیامک بریده نمی‌شود', () => {
+52 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access';
import {
customersCompanyWhere,
customersPersonWhere,
leadsDeepWhere,
resolveSectionAccess,
} from '../src/common/data-access';
import {
noteAccessWhere,
normalizeDataVisibility,
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخش‌ها را ذ
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
});
});
describe('مسئول گروه مشتری روی عمق داده', () => {
const selfAccess = { mode: 'rules' as const, rules: [{ owners: ['self' as const] }] };
it('محدودیت self با OR مسئول گروه برای شخص', () => {
const where = customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('محدودیت self با OR مسئول گروه برای شرکت', () => {
const where = customersCompanyWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('بدون محدودیت عمقی، فیلتر اضافه نمی‌شود', () => {
assert.deepEqual(
customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: { mode: 'all', rules: [] },
teamPeerIds: null,
}),
{},
);
});
});
@@ -126,6 +126,9 @@ function apply() {
<strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header>
<p v-if="isPeople" class="facet-hint">
گروه، منبع و وضعیت با هم جمع می‌شوند؛ هر کدام کافی است (بدون تکرار).
</p>
<div class="panel-body">
<section v-if="hasSort" class="sec">
@@ -264,6 +267,15 @@ function apply() {
border-bottom: 1px solid var(--line);
}
.facet-hint {
margin: 0;
padding: 0.35rem 0.9rem 0.55rem;
font-size: var(--fs-sm);
color: #64748b;
line-height: 1.45;
border-bottom: 1px solid var(--line);
}
.panel-foot {
border-top: 1px solid var(--line);
justify-content: flex-end;
+237 -32
View File
@@ -1,29 +1,54 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import type { CustomerGroupColor, CustomerGroupDto, OrgUserDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import {
CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch,
useCustomerGroups,
} from '@/lib/customer-groups';
import { useAuthStore } from '@/stores/auth';
import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const auth = useAuthStore();
const canManage = computed(
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
);
const { groups, load } = useCustomerGroups();
const users = ref<OrgUserDto[]>([]);
const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal');
const newAssigneeIds = ref<string[]>([]);
const editingId = ref<string | null>(null);
const editName = ref('');
const assigneeDraft = ref<Record<string, string[]>>({});
const listQuery = ref('');
const busy = ref(false);
const error = ref('');
const filteredGroups = computed(() => {
const q = normalizeSearchText(listQuery.value);
if (!q) return groups.value;
return groups.value.filter((g) => {
const assigneeNames = (g.assignees ?? []).map((a) => a.fullName).join(' ');
return (
normalizeSearchText(g.name).includes(q) ||
normalizeSearchText(g.parentName ?? '').includes(q) ||
normalizeSearchText(assigneeNames).includes(q)
);
});
});
function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
return fallback;
}
async function loadUsers() {
try {
const { data } = await api.get<OrgUserDto[]>('/users');
users.value = data.filter((u) => u.isActive !== false);
} catch {
users.value = [];
}
}
function syncAssigneeDraft() {
const next: Record<string, string[]> = {};
for (const g of groups.value) {
next[g.id] = (g.assignees ?? []).map((a) => a.id);
}
assigneeDraft.value = next;
}
async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true;
error.value = '';
try {
await task();
await load(true);
syncAssigneeDraft();
emit('changed');
return true;
} catch (e) {
@@ -53,10 +96,18 @@ async function create() {
const name = newName.value.trim();
if (!name) return;
const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }),
() =>
api.post('/customer-groups', {
name,
color: newColor.value,
assigneeIds: newAssigneeIds.value,
}),
'ساخت گروه ناموفق بود.',
);
if (ok) newName.value = '';
if (ok) {
newName.value = '';
newAssigneeIds.value = [];
}
}
function startEdit(g: CustomerGroupDto) {
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
}
async function onAssigneesChange(g: CustomerGroupDto, ids: string[]) {
assigneeDraft.value = { ...assigneeDraft.value, [g.id]: ids };
const prev = (g.assignees ?? []).map((a) => a.id).sort().join(',');
const next = [...ids].sort().join(',');
if (prev === next) return;
await run(
() => api.patch(`/customer-groups/${g.id}`, { assigneeIds: ids }),
'ذخیره مسئولان گروه ناموفق بود.',
);
}
function assigneeLabel(g: CustomerGroupDto) {
const names = (g.assignees ?? []).map((a) => a.fullName).filter(Boolean);
if (!names.length) return 'بدون مسئول';
if (names.length === 1) return names[0]!;
return `${names[0]} و ${toFaDigits(names.length - 1)} نفر دیگر`;
}
async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
@@ -95,9 +164,12 @@ watch(
if (!open) return;
error.value = '';
editingId.value = null;
void load(true).catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
listQuery.value = '';
void Promise.all([load(true), loadUsers()])
.then(() => syncAssigneeDraft())
.catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
},
{ immediate: true },
);
@@ -107,34 +179,60 @@ watch(
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager">
<p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب
«ته‌حساب» مشخص شده‌اند.
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
مشتریان خود می‌بینند. گروه‌های ته‌حساب با برچسب «ته‌حساب» مشخص شده‌اند.
</p>
<form class="new-row" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
<form v-if="canManage" class="create-panel" @submit.prevent="create">
<header class="create-head">
<strong>گروه جدید</strong>
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
</header>
<div class="new-row">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</div>
<div v-if="users.length" class="assignee-field create-assignees">
<span class="assignees-label">مسئولان (اختیاری)</span>
<OrgUserPersonPick
v-model="newAssigneeIds"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list">
<li v-for="g in groups" :key="g.id" class="row">
<div v-if="canManage || groups.length" class="list-head">
<h3 class="list-title">گروه‌های موجود</h3>
<label v-if="groups.length" class="list-search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجوی گروه یا مسئول…"
/>
</label>
</div>
<ul v-if="filteredGroups.length" class="list">
<li v-for="g in filteredGroups" :key="g.id" class="row">
<div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
@@ -152,9 +250,10 @@ watch(
<span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span>
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
</template>
</div>
<div v-if="editingId !== g.id" class="row-actions">
<div v-if="editingId !== g.id && canManage" class="row-actions">
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
@@ -173,8 +272,20 @@ watch(
<UiIcon name="trash" :size="14" />
</UiButton>
</div>
<div v-if="canManage && users.length" class="assignee-field">
<span class="assignees-label">مسئولان</span>
<OrgUserPersonPick
:model-value="assigneeDraft[g.id] ?? []"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
@update:model-value="(ids) => (assigneeDraft[g.id] = ids)"
@change="(ids) => onAssigneesChange(g, ids)"
/>
</div>
</li>
</ul>
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div>
</UiModal>
@@ -200,6 +311,78 @@ watch(
font-size: 0.85rem;
}
.create-panel {
display: grid;
gap: 0.75rem;
padding: 0.85rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--accent, #0d9488) 28%, #e2e8f0);
border-radius: 12px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, #fff) 0%, #fff 72%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.create-head {
display: grid;
gap: 0.15rem;
}
.create-head strong {
color: var(--accent-deep, #0f766e);
font-size: 0.9rem;
font-weight: 700;
}
.create-head span {
color: #64748b;
font-size: 0.78rem;
}
.list-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.list-title {
margin: 0;
color: #475569;
font-size: 0.8rem;
font-weight: 700;
}
.list-search {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: min(16rem, 100%);
flex: 1 1 12rem;
max-width: 18rem;
height: 2.25rem;
padding: 0 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
color: #94a3b8;
}
.list-search:focus-within {
border-color: #5eead4;
}
.list-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.85rem;
color: #0f172a;
}
.new-row,
.edit {
display: flex;
@@ -240,11 +423,9 @@ watch(
.list {
list-style: none;
margin: 0;
padding: 0;
padding: 0 0 0.25rem;
display: grid;
gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
}
.row {
@@ -282,14 +463,38 @@ watch(
padding: 0.05rem 0.4rem;
}
.count {
.count,
.assignee-summary {
color: #94a3b8;
font-size: 0.78rem;
}
.assignee-summary {
flex: 1 1 100%;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.assignee-field {
flex: 1 1 100%;
display: grid;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed #e2e8f0;
}
.create-assignees {
border-top: 1px dashed color-mix(in srgb, var(--accent, #0d9488) 22%, #e2e8f0);
padding-top: 0.65rem;
}
.assignees-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
}
</style>
+33 -24
View File
@@ -22,7 +22,6 @@ const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[];
pipelines?: IdOption[];
boards?: IdOption[];
@@ -36,7 +35,6 @@ const props = withDefaults(
}>(),
{
section: 'customers',
groups: () => [],
stages: () => [],
pipelines: () => [],
boards: () => [],
@@ -53,9 +51,9 @@ const emit = defineEmits<{
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers');
const isCustomers = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets');
@@ -119,9 +117,8 @@ const boardsLegend = computed(() =>
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! };
if (rules.length === 1) return stripCustomerGroupIds({ ...rules[0]! });
const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>();
const moods = new Set<PersonMood>();
const stageIds = new Set<string>();
@@ -130,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
const typeIds = new Set<string>();
for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id);
@@ -140,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
}
const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds];
@@ -150,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
return out;
}
/** دسترسی گروه فقط از مسئول‌گروه؛ groupIds دیگر در فیلتر مشتریان ذخیره/نمایش نمی‌شود */
function stripCustomerGroupIds(rule: DataAccessRule): DataAccessRule {
if (!isCustomers.value || !rule.groupIds?.length) return rule;
const { groupIds: _drop, ...rest } = rule;
return rest;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean(
rule.owners?.length ||
rule.groupIds?.length ||
(!isCustomers.value && rule.groupIds?.length) ||
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
@@ -184,10 +186,26 @@ const mode = computed({
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
/** پاک‌سازی groupIds قدیمی مشتریان هنگام باز شدن ویرایشگر */
watch(
() => [props.section, props.modelValue] as const,
() => {
if (!isCustomers.value || props.modelValue?.mode !== 'rules') return;
const rules = props.modelValue.rules ?? [];
if (!rules.some((r) => r.groupIds?.length)) return;
const collapsed = collapseRules(rules);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
{ immediate: true },
);
function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (!isCustomers.value && next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
@@ -210,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
@@ -293,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
const pickers = computed((): Picker[] => {
const rule = filter.value;
const items: Picker[] = [];
if (showGroups.value) {
items.push({
key: 'groupIds',
legend: 'گروه‌ها',
mode: 'multiple',
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
options: withSelected(namedOptions(props.groups), rule.groupIds),
});
}
if (showImportSources.value) {
const ready = props.importSourcesReady;
const failed = props.importSourcesFailed;
@@ -417,7 +424,6 @@ const filterSummary = computed(() => {
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
@@ -443,9 +449,12 @@ const filterSummary = computed(() => {
<template v-if="mode === 'rules'">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
فیلترهای پرشده با هم
<strong>جمع می‌شوند</strong>
؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
<template v-if="isCustomers">
اعضای گروه‌هایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه می‌شوند.
</template>
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
@@ -16,7 +16,6 @@ import {
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
@@ -85,7 +84,6 @@ function setSection(key: DataAccessSection, next: SectionDataAccess) {
});
}
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false);
const importSourcesFailed = ref(false);
@@ -142,7 +140,6 @@ async function loadTreasuryAccounts() {
}
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
@@ -362,7 +359,6 @@ const typesForActive = computed(() => {
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
+9 -2
View File
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string;
clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(),
{
mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false,
clearable: true,
icon: undefined,
displayText: '',
startView: 'days',
},
);
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
});
const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return '';
@@ -353,7 +360,7 @@ function place() {
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
@@ -368,7 +375,7 @@ function onWindowChange() {
async function show() {
if (props.disabled) return;
syncDraftFrom(parsed.value);
calView.value = 'days';
calView.value = props.startView;
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean;
dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(),
{
required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2,
asNumber: false,
dir: 'ltr',
addonAfter: '',
},
);
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled"
:readonly="readonly"
:value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus"
@blur="onBlur"
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import type { OrgUserDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
const props = withDefaults(
defineProps<{
modelValue: string[];
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
disabled?: boolean;
emptyLabel?: string;
}>(),
{
disabled: false,
emptyLabel: 'انتخاب مسئول',
},
);
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const searchInput = ref<HTMLInputElement | null>(null);
const selected = computed(() =>
props.modelValue
.map((id) => props.users.find((u) => u.id === id))
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
);
const label = computed(() => {
if (!selected.value.length) return props.emptyLabel;
if (selected.value.length === 1) return selected.value[0]!.fullName;
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
});
const filtered = computed(() => {
const q = normalizeSearchText(query.value);
if (!q) return props.users;
return props.users.filter(
(u) =>
normalizeSearchText(u.fullName).includes(q) ||
normalizeSearchText(u.orgRoleName ?? '').includes(q),
);
});
function toggleMenu() {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
void nextTick(() => searchInput.value?.focus());
}
}
function close() {
open.value = false;
}
function setIds(next: string[]) {
emit('update:modelValue', next);
emit('change', next);
}
function toggle(userId: string) {
if (props.disabled) return;
const set = new Set(props.modelValue);
if (set.has(userId)) set.delete(userId);
else set.add(userId);
setIds([...set]);
}
function remove(userId: string) {
if (props.disabled) return;
if (!props.modelValue.includes(userId)) return;
setIds(props.modelValue.filter((id) => id !== userId));
}
watch(open, (isOpen) => {
if (!isOpen) query.value = '';
});
defineExpose({ close });
</script>
<template>
<div class="wrap" @click.stop>
<button
class="picker person-pick"
type="button"
:disabled="disabled"
@click="toggleMenu"
>
<div class="assignee-avatars">
<UserAvatar
v-for="user in selected.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
</div>
<span :class="{ ghost: !selected.length }">{{ label }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<div v-if="selected.length" class="chips">
<span v-for="user in selected" :key="user.id" class="chip">
{{ user.fullName }}
<button
type="button"
class="chip-x"
:disabled="disabled"
:aria-label="`حذف ${user.fullName}`"
@click="remove(user.id)"
>
×
</button>
</span>
</div>
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="searchInput"
v-model="query"
type="search"
placeholder="جستجوی نام یا نقش…"
@keydown.esc="close"
/>
</label>
<ul class="person-list">
<li v-for="user in filtered" :key="user.id">
<button
type="button"
class="multi-option"
:class="{ on: modelValue.includes(user.id) }"
@click="toggle(user.id)"
>
<span class="check" :class="{ on: modelValue.includes(user.id) }">
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<span class="role-name">{{ user.fullName }}</span>
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
</button>
</li>
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
</ul>
</div>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 12rem;
}
.picker {
width: 100%;
min-height: var(--control-h, 36px);
height: var(--control-h, 36px);
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.picker:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.picker .ghost {
color: #94a3b8;
font-weight: 500;
}
.picker :deep(.ico:last-child) {
margin-inline-start: auto;
color: #94a3b8;
transform: rotate(90deg);
}
.assignee-avatars {
display: flex;
align-items: center;
}
.assignee-avatars :deep(.avatar),
.assignee-avatars > * {
margin-inline-end: -0.35rem;
box-shadow: 0 0 0 2px #fff;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #99f6e4;
color: #0f766e;
font-size: 0.78rem;
font-weight: 600;
}
.chip-x {
appearance: none;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.2rem;
border-radius: 999px;
}
.chip-x:hover:not(:disabled) {
background: #ccfbf1;
}
.chip-x:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: absolute;
inset-inline: 0;
top: calc(100% + 0.25rem);
z-index: 40;
margin: 0;
background: #fff;
border-radius: var(--radius, 10px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
}
.person-menu {
max-height: none;
overflow: visible;
display: grid;
gap: 0.35rem;
padding: 0.45rem;
border: 1px solid #e2e8f0;
}
.person-search {
display: flex;
align-items: center;
gap: 0.45rem;
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
color: #94a3b8;
}
.person-search:focus-within {
border-color: #5eead4;
}
.person-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: #0f172a;
}
.person-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 15rem;
overflow-y: auto;
}
.multi-menu .multi-option {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: var(--radius, 10px);
font: inherit;
text-align: start;
cursor: pointer;
}
.multi-menu .multi-option:hover {
background: #f8fafc;
}
.multi-menu .multi-option.on {
background: #f0fdfa;
}
.multi-menu .check {
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.multi-menu .check.on {
background: #14b8a6;
border-color: #14b8a6;
}
.multi-menu .role-name {
min-width: 0;
}
.multi-menu .role-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.menu-empty {
padding: 0.6rem;
color: #94a3b8;
font-size: 0.82rem;
}
</style>
+632 -71
View File
@@ -1,10 +1,23 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import axios from 'axios';
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
import type {
OutboundSmsHistoryResponse,
OutboundSmsLogDto,
PersonAssetsDto,
SmsTemplateDto,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_FIELDS,
SMS_TEMPLATE_BODY_MAX,
SMS_TEMPLATE_MAX,
SMS_TEMPLATE_NAME_MAX,
countSmsParts,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
import { useAuthStore } from '@/stores/auth';
import { UiButton, UiModal } from '@/components/ui';
const props = withDefaults(
@@ -25,7 +38,7 @@ const props = withDefaults(
recordTitle: '',
company: '',
title: 'ارسال پیامک',
defaultText: 'سلام {نام} عزیز',
defaultText: '',
nest: false,
},
);
@@ -35,18 +48,73 @@ const emit = defineEmits<{
sent: [];
}>();
type Panel = 'none' | 'fields' | 'templates';
const auth = useAuthStore();
const text = ref('');
const busy = ref(false);
const error = ref('');
const done = ref('');
const fieldsOpen = ref(false);
const notice = ref('');
const panel = ref<Panel>('none');
const savingTemplate = ref(false);
const templateName = ref('');
const templateBody = ref('');
const bodyEl = ref<HTMLTextAreaElement | null>(null);
const templateBodyEl = ref<HTMLTextAreaElement | null>(null);
const history = ref<OutboundSmsLogDto[]>([]);
const historyLoading = ref(false);
const templates = ref<SmsTemplateDto[]>([]);
const templatesLoading = ref(false);
const fromOrg = ref('');
const rialBalanceText = ref('');
const metalBalanceText = ref('');
const METAL_FIELD_LABELS: Record<string, string> = {
MandeyeVazni: 'طلا',
MandeyeVazniNoghre: 'نقره',
MandeyeKolBeTALA: 'کل به طلا',
MandeMenhayeSeke: 'منهای سکه',
};
function isMetalMandehKey(key: string) {
if (METAL_FIELD_LABELS[key]) return true;
if (key.startsWith('Seke_')) return true;
return /Vazni|طلا|نقره|فلز|Gold|Silver/i.test(key);
}
function metalFieldLabel(key: string) {
if (METAL_FIELD_LABELS[key]) return METAL_FIELD_LABELS[key]!;
if (key.startsWith('Seke_')) return `سکه ${key.slice(5)}`;
return key;
}
function applyAssetsToVars(assets: PersonAssetsDto) {
const fields = assets.mandehFields ?? [];
const mali = fields.find((f) => f.key === 'MandeyeMali' || f.key === 'MandeyeKolBePool');
const rialRaw = mali?.value ?? assets.rialBalance;
rialBalanceText.value =
rialRaw == null || String(rialRaw).trim() === ''
? ''
: formatTahesabBedBes(rialRaw);
const metalParts = fields
.filter((f) => isMetalMandehKey(f.key) && f.value != null && String(f.value).trim() !== '')
.map((f) => `${metalFieldLabel(f.key)} ${formatTahesabBedBes(f.value)}`.trim());
metalBalanceText.value =
metalParts.join('، ') ||
(assets.goldBalance != null ? `طلا ${formatTahesabBedBes(assets.goldBalance)}` : '');
}
const preview = computed(() => fill(text.value));
const count = computed(() => text.value.length);
const parts = computed(() => countSmsParts(text.value));
const canSaveTemplate = computed(
() =>
templateName.value.trim().length >= 1 &&
templateBody.value.trim().length >= 2 &&
templates.value.length < SMS_TEMPLATE_MAX &&
!savingTemplate.value,
);
watch(
() => props.open,
@@ -55,12 +123,43 @@ watch(
text.value = props.defaultText;
error.value = '';
done.value = '';
fieldsOpen.value = false;
notice.value = '';
panel.value = 'none';
templateName.value = '';
templateBody.value = '';
busy.value = false;
fromOrg.value = auth.user?.organizationName?.trim() || '';
rialBalanceText.value = '';
metalBalanceText.value = '';
void loadHistory();
void loadTemplates();
void loadContextVars();
},
);
async function loadContextVars() {
if (!fromOrg.value) {
try {
const { data } = await api.get<{ name?: string }>('/organizations/current');
if (data?.name?.trim()) fromOrg.value = data.name.trim();
} catch {
/* نام سازمان از auth کافی است */
}
}
const personMatch = props.endpoint.match(/\/people\/([^/]+)\/(?:sms|birthday-sms)/);
if (!personMatch?.[1]) return;
try {
const { data } = await api.get<PersonAssetsDto>(
`/integrations/tahesab/people/${personMatch[1]}/assets`,
{ params: { refresh: '0' } },
);
applyAssetsToVars(data);
} catch {
rialBalanceText.value = '';
metalBalanceText.value = '';
}
}
async function loadHistory() {
history.value = [];
if (!props.historyEndpoint) return;
@@ -77,41 +176,158 @@ async function loadHistory() {
}
}
async function loadTemplates() {
templatesLoading.value = true;
try {
const { data } = await api.get<SmsTemplateDto[]>('/sms-templates');
templates.value = Array.isArray(data) ? data : [];
} catch (e) {
templates.value = [];
error.value = apiMessage(e, 'بارگذاری پیامک‌های آماده ناموفق بود.');
} finally {
templatesLoading.value = false;
}
}
const EMPTY_FIELD_HINT: Record<string, string> = {
عنوان: '〔عنوان ثبت نشده〕',
title: '〔عنوان ثبت نشده〕',
شرکت: '〔شرکت مشتری ثبت نشده〕',
company: '〔شرکت مشتری ثبت نشده〕',
نام: '〔نام ثبت نشده〕',
name: '〔نام ثبت نشده〕',
شماره: '〔شماره ثبت نشده〕',
phone: '〔شماره ثبت نشده〕',
'از طرف': '〔سازمان ثبت نشده〕',
from: '〔سازمان ثبت نشده〕',
'مانده ریالی': '〔مانده ریالی نیست〕',
rialBalance: '〔مانده ریالی نیست〕',
'مانده فلزی': '〔مانده فلزی نیست〕',
metalBalance: '〔مانده فلزی نیست〕',
};
function normalizeTemplateKey(key: string) {
return String(key ?? '')
.normalize('NFKC')
.replace(/[\u200c\u200d\u200e\u200f\ufeff]/g, '')
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
}
/** پیش‌نمایش؛ اگر مقدار خالی باشد نشانه می‌گذارد تا با فیلد خالی قاطی نشود. */
function fill(source: string) {
const map: Record<string, string> = {
نام: props.name,
name: props.name,
شماره: props.phone,
phone: props.phone,
تاریخ: formatJalaliDay(new Date()),
date: formatJalaliDay(new Date()),
عنوان: props.recordTitle || props.name,
title: props.recordTitle || props.name,
شرکت: props.company,
company: props.company,
};
return source.replace(/\{([^}]+)\}/g, (full, key: string) => {
const next = map[key.trim()];
return next === undefined ? full : next;
const from = fromOrg.value.trim();
const rial = rialBalanceText.value.trim();
const metal = metalBalanceText.value.trim();
const entries: Array<[string, string]> = [
['نام', props.name.trim()],
['name', props.name.trim()],
['شماره', props.phone.trim()],
['phone', props.phone.trim()],
['تاریخ', formatJalaliDay(new Date())],
['date', formatJalaliDay(new Date())],
['عنوان', props.recordTitle.trim()],
['title', props.recordTitle.trim()],
['شرکت', props.company.trim()],
['company', props.company.trim()],
['از طرف', from],
['ازطرف', from],
['from', from],
['مانده ریالی', rial],
['ماندهریالی', rial],
['rialBalance', rial],
['مانده فلزی', metal],
['ماندهفلزی', metal],
['metalBalance', metal],
];
const map: Record<string, string> = {};
const hints: Record<string, string> = {};
for (const [key, value] of entries) {
const norm = normalizeTemplateKey(key);
map[norm] = value;
if (EMPTY_FIELD_HINT[key]) hints[norm] = EMPTY_FIELD_HINT[key]!;
}
return source.replace(/\{([^}]+)\}/g, (full, rawKey: string) => {
const key = normalizeTemplateKey(rawKey);
if (!(key in map)) return full;
const next = map[key] ?? '';
if (next) return next;
return hints[key] ?? full;
});
}
function insertField(token: string) {
const el = bodyEl.value;
const current = text.value;
function insertInto(
target: 'compose' | 'draft',
token: string,
) {
const el = target === 'compose' ? bodyEl.value : templateBodyEl.value;
const current = target === 'compose' ? text.value : templateBody.value;
const setValue = (next: string) => {
if (target === 'compose') text.value = next;
else templateBody.value = next;
};
if (!el) {
text.value = `${current}${token}`;
} else {
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? start;
text.value = `${current.slice(0, start)}${token}${current.slice(end)}`;
const caret = start + token.length;
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(caret, caret);
});
setValue(`${current}${token}`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? start;
setValue(`${current.slice(0, start)}${token}${current.slice(end)}`);
const caret = start + token.length;
void nextTick(() => {
el.focus();
el.setSelectionRange(caret, caret);
});
}
function openPanel(next: Panel) {
panel.value = panel.value === next ? 'none' : next;
notice.value = '';
if (panel.value === 'templates') {
error.value = '';
void loadTemplates();
}
}
function applyTemplate(item: SmsTemplateDto) {
text.value = item.body;
error.value = '';
notice.value = `قالب «${item.name}» در متن پیام قرار گرفت.`;
}
async function saveTemplate() {
const name = templateName.value.trim().slice(0, SMS_TEMPLATE_NAME_MAX);
const body = templateBody.value.trim().slice(0, SMS_TEMPLATE_BODY_MAX);
if (!canSaveTemplate.value || !name || body.length < 2) return;
savingTemplate.value = true;
error.value = '';
notice.value = '';
try {
const { data } = await api.post<SmsTemplateDto[]>('/sms-templates', { name, body });
templates.value = Array.isArray(data) ? data : [];
templateName.value = '';
templateBody.value = '';
notice.value = `پیامک آماده «${name}» ذخیره شد.`;
} catch (e) {
error.value = apiMessage(e, 'ذخیره پیامک آماده ناموفق بود.');
} finally {
savingTemplate.value = false;
}
}
async function removeTemplate(item: SmsTemplateDto) {
if (!confirm(`پیامک آماده «${item.name}» حذف شود؟`)) return;
error.value = '';
notice.value = '';
try {
const { data } = await api.delete<SmsTemplateDto[]>(`/sms-templates/${item.id}`);
templates.value = Array.isArray(data) ? data : [];
notice.value = `«${item.name}» حذف شد.`;
} catch (e) {
error.value = apiMessage(e, 'حذف پیامک آماده ناموفق بود.');
}
fieldsOpen.value = false;
}
function apiMessage(e: unknown, fallback: string) {
@@ -122,17 +338,28 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
async function send() {
async function send(force = false) {
if (!props.endpoint || busy.value || done.value) return;
busy.value = true;
error.value = '';
notice.value = '';
try {
await api.post(props.endpoint, { text: text.value });
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
done.value = 'پیامک ارسال شد.';
emit('sent');
void loadHistory();
} catch (e) {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
if (axios.isAxiosError(e) && e.response?.status === 409) {
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
if (confirm(msg)) {
busy.value = false;
await send(true);
return;
}
error.value = msg;
} else {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
}
} finally {
busy.value = false;
}
@@ -152,22 +379,141 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
</p>
<div class="tools">
<button type="button" class="pill" @click="fieldsOpen = !fieldsOpen">فیلدهای پویا</button>
<div v-if="fieldsOpen" class="fields">
<div class="tool-row">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
class="pill"
:class="{ on: panel === 'fields' }"
@click="openPanel('fields')"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
فیلدهای پویا
</button>
<button
type="button"
class="pill"
:class="{ on: panel === 'templates' }"
@click="openPanel('templates')"
>
ساخت پیامک آماده
</button>
</div>
<div v-if="panel === 'fields'" class="panel">
<p class="panel-hint">روی فیلد بزنید تا داخل متن پیام درج شود.</p>
<div class="field-grid">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertInto('compose', item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-if="panel === 'templates'" class="panel">
<div class="draft">
<strong>ساخت پیامک آماده جدید</strong>
<p class="panel-hint">این متن جدا از پیام ارسالی است؛ بعد از ذخیره، عنوانش بالا برای استفاده سریع می‌آید.</p>
<input
v-model="templateName"
type="text"
:maxlength="SMS_TEMPLATE_NAME_MAX"
placeholder="نام قالب (مثلاً پیگیری جلسه)"
autocomplete="off"
/>
<textarea
ref="templateBodyEl"
v-model="templateBody"
rows="3"
:maxlength="SMS_TEMPLATE_BODY_MAX"
placeholder="متن قالب، مثلاً: سلام {نام} عزیز…"
/>
<div class="tpl-fields" role="group" aria-label="فیلدهای پویا برای قالب">
<span class="tpl-fields-label">درج فیلد در قالب:</span>
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="`tpl-${item.token}`"
type="button"
:title="item.label"
@click="insertInto('draft', item.token)"
>
<code>{{ item.token }}</code>
</button>
</div>
<button
type="button"
class="save-btn"
:disabled="!canSaveTemplate"
@click="saveTemplate"
>
{{ savingTemplate ? 'در حال ذخیره…' : 'ذخیره قالب' }}
</button>
<p v-if="templates.length >= SMS_TEMPLATE_MAX" class="muted">
سقف {{ toFaDigits(SMS_TEMPLATE_MAX) }} قالب پر شده است.
</p>
</div>
<div v-if="templates.length" class="manage-list">
<p class="panel-hint">حذف قالب‌های ذخیره‌شده:</p>
<ul class="template-list">
<li v-for="item in templates" :key="`del-${item.id}`">
<span class="pick-name">{{ item.name }}</span>
<button
type="button"
class="del"
:aria-label="`حذف ${item.name}`"
@click="removeTemplate(item)"
>
حذف
</button>
</li>
</ul>
</div>
</div>
</div>
<textarea ref="bodyEl" v-model="text" rows="4" maxlength="500" placeholder="سلام {نام} عزیز" />
<p class="count">{{ toFaDigits(count) }} نویسه · {{ toFaDigits(parts) }} پیامک</p>
<div class="compose-layout">
<div class="compose-main">
<label class="compose-label" for="sms-compose-body">متن پیام ارسالی</label>
<textarea
id="sms-compose-body"
ref="bodyEl"
v-model="text"
rows="5"
maxlength="500"
placeholder="متن پیام را بنویسید یا از ستون قالب‌ها انتخاب کنید"
/>
<p class="count">{{ toFaDigits(count) }} نویسه · {{ toFaDigits(parts) }} پیامک</p>
</div>
<aside class="quick-col" aria-label="انتخاب سریع پیامک آماده">
<strong class="quick-col-title">قالب‌ها</strong>
<div v-if="templatesLoading" class="muted tiny">در حال بارگذاری…</div>
<div
v-else-if="templates.length"
class="quick-templates"
role="list"
>
<button
v-for="item in templates"
:key="item.id"
type="button"
class="tpl-chip"
role="listitem"
:title="item.body"
@click="applyTemplate(item)"
>
{{ item.name }}
</button>
</div>
<p v-else class="muted tiny quick-empty">
هنوز قالبی نیست. از «ساخت پیامک آماده» بسازید.
</p>
</aside>
</div>
<div class="preview">
<span>پیش‌نمایش</span>
@@ -195,6 +541,7 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
</div>
<p v-if="error" class="err">{{ error }}</p>
<p v-else-if="notice" class="ok">{{ notice }}</p>
<p v-if="done" class="ok">{{ done }}</p>
<template #footer>
@@ -234,66 +581,279 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
}
.tools {
position: relative;
margin-bottom: 0.45rem;
margin-bottom: 0.55rem;
}
.pill,
.fields button {
.tool-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pill {
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
background: var(--accent-subtle);
color: var(--accent-deep);
border-radius: 999px;
font: inherit;
cursor: pointer;
}
.pill {
padding: 0.28rem 0.7rem;
font-size: 0.8rem;
}
.fields {
position: absolute;
z-index: 2;
top: calc(100% + 0.3rem);
inset-inline: 0;
.pill.on {
background: var(--accent-deep);
color: #fff;
border-color: var(--accent-deep);
}
.panel {
margin-top: 0.45rem;
display: grid;
gap: 0.25rem;
max-width: 100%;
padding: 0.4rem;
gap: 0.45rem;
padding: 0.55rem;
background: #fff;
border: 1px solid #e7eef2;
border-radius: 12px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.fields button {
.panel-hint {
margin: 0;
color: #64748b;
font-size: 0.75rem;
}
.field-grid {
display: grid;
gap: 0.25rem;
}
.field-grid button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
border-radius: 8px;
padding: 0.35rem 0.5rem;
background: #fff;
text-align: start;
}
.fields code {
font: inherit;
cursor: pointer;
color: var(--accent-deep);
}
textarea {
.field-grid code {
color: var(--accent-deep);
}
.template-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
max-height: 9.5rem;
overflow: auto;
}
.template-list li {
display: flex;
align-items: stretch;
gap: 0.3rem;
}
.compose-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 10.5rem;
gap: 0.75rem;
align-items: stretch;
margin-bottom: 0.15rem;
}
.compose-main {
min-width: 0;
display: grid;
align-content: start;
}
.quick-col {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 0;
max-height: 11.5rem;
padding: 0.5rem;
border: 1px solid #e7eef2;
border-radius: 12px;
background: #fafbfc;
}
.quick-col-title {
font-size: 0.78rem;
color: #0f172a;
}
.quick-templates {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-height: 0;
overflow: auto;
flex: 1;
padding-inline-end: 0.1rem;
}
.tpl-chip {
flex: none;
width: 100%;
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
background: #fff;
color: var(--accent-deep);
border-radius: 8px;
font: inherit;
font-size: 0.76rem;
font-weight: 600;
padding: 0.4rem 0.5rem;
cursor: pointer;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tpl-chip:hover {
background: var(--accent-subtle);
}
.quick-empty {
margin: 0;
}
.tiny {
font-size: 0.72rem;
}
@media (max-width: 640px) {
.compose-layout {
grid-template-columns: 1fr;
}
.quick-col {
max-height: 8.5rem;
}
}
.manage-list {
padding-top: 0.45rem;
border-top: 1px solid #eef2f7;
}
.template-list .pick-name {
flex: 1;
min-width: 0;
padding: 0.4rem 0.5rem;
font-size: 0.82rem;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-list .del {
flex: none;
border: 0;
border-radius: 8px;
background: transparent;
color: #b91c1c;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
padding: 0.35rem 0.45rem;
}
.draft {
display: grid;
gap: 0.35rem;
padding-top: 0.45rem;
border-top: 1px solid #eef2f7;
}
.draft strong {
font-size: 0.86rem;
color: #0f172a;
}
.draft input,
.draft textarea,
#sms-compose-body {
width: 100%;
box-sizing: border-box;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.7rem 0.8rem;
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
resize: vertical;
color: #0f172a;
}
.draft textarea,
#sms-compose-body {
resize: vertical;
}
.tpl-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.tpl-fields-label {
color: #64748b;
font-size: 0.74rem;
margin-inline-end: 0.15rem;
}
.tpl-fields button {
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
background: #fff;
color: var(--accent-deep);
border-radius: 999px;
font: inherit;
font-size: 0.74rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.tpl-fields code {
color: inherit;
}
.save-btn {
justify-self: start;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #fff;
font: inherit;
font-size: 0.8rem;
padding: 0.45rem 0.75rem;
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.compose-label {
display: block;
margin: 0.15rem 0 0.35rem;
color: #475569;
font-size: 0.8rem;
font-weight: 600;
}
.count {
margin: 0.35rem 0 0;
color: #94a3b8;
@@ -316,6 +876,7 @@ textarea {
margin: 0.3rem 0 0;
color: #134e4a;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.history {
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
import { LeftOutlined } from '@ant-design/icons-vue';
export type StagePathItem = {
id: string;
name: string;
};
const props = withDefaults(
defineProps<{
stages: StagePathItem[];
value?: string | null;
loading?: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(),
{
value: null,
loading: false,
disabled: false,
ariaLabel: 'مراحل',
},
);
const emit = defineEmits<{
'update:value': [id: string];
select: [id: string];
}>();
const { token } = theme.useToken();
const currentIndex = computed(() =>
props.stages.findIndex((stage) => stage.id === props.value),
);
const stripStyle = computed(() => ({
width: '100%',
overflowX: 'auto' as const,
paddingBottom: '2px',
}));
const sepStyle = computed(() => ({
color: token.value.colorTextQuaternary,
fontSize: '10px',
flexShrink: 0,
}));
function onPick(id: string) {
if (props.disabled || props.loading || id === props.value) return;
emit('update:value', id);
emit('select', id);
}
</script>
<template>
<div
role="listbox"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
:style="stripStyle"
>
<a-flex align="center" :gap="6" wrap="nowrap" :style="{ width: 'max-content', minWidth: '100%' }">
<template v-for="(stage, index) in stages" :key="stage.id">
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
<a-button
role="option"
:aria-selected="stage.id === value"
:type="stage.id === value ? 'primary' : 'default'"
:disabled="disabled || loading"
:style="{
borderRadius: '999px',
flexShrink: 0,
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
opacity: index < currentIndex ? 0.85 : 1,
}"
@click="onPick(stage.id)"
>
{{ stage.name }}
</a-button>
</template>
</a-flex>
</div>
</template>
+14 -5
View File
@@ -18,8 +18,10 @@ const props = withDefaults(
tableKey: string;
label?: string;
align?: 'start' | 'end';
/** فقط آیکون تنظیمات (بدون متن «ستون‌ها») */
iconOnly?: boolean;
}>(),
{ label: 'ستون‌ها', align: 'start' },
{ label: 'ستون‌ها', align: 'start', iconOnly: false },
);
const open = ref(false);
@@ -60,11 +62,18 @@ const placement = computed(() =>
</a-button>
</a-flex>
</template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
<a-button
:type="hiddenCount > 0 ? 'primary' : 'default'"
:ghost="hiddenCount > 0"
:title="iconOnly ? (hiddenCount ? `${label} · ${toFaDigits(hiddenCount)} مخفی` : label) : undefined"
:aria-label="label"
>
<template #icon><SettingOutlined /></template>
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
<template v-if="!iconOnly">
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</template>
</a-button>
</a-popover>
+90 -4
View File
@@ -22,6 +22,7 @@ import {
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { matchesSearch } from '@/lib/search';
const props = defineProps<{
users: OrgUserDto[];
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
const teamsQuery = ref('');
const accessQuery = ref('');
const filteredTeams = computed(() => {
const q = teamsQuery.value.trim();
if (!q) return teams.value;
return teams.value.filter((team) =>
matchesSearch(q, [
team.name,
team.description,
...team.members.map((m) => m.fullName),
]),
);
});
const filteredAccessUsers = computed(() => {
const q = accessQuery.value.trim();
if (!q) return editableUsers.value;
return editableUsers.value.filter((u) =>
matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName, accessSummary(u)]),
);
});
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
priorPick.value = '';
@@ -383,14 +407,22 @@ onMounted(() => {
</section>
<section class="card">
<h3>تیم‌ها</h3>
<header class="card-head">
<h3>تیم‌ها</h3>
<UiInput
v-if="teams.length"
v-model="teamsQuery"
placeholder="جستجوی تیم یا عضو…"
/>
</header>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<article v-for="t in filteredTeams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
@@ -504,7 +536,14 @@ onMounted(() => {
</section>
<section class="card">
<h3>محدوده داده</h3>
<header class="card-head">
<h3>محدوده داده</h3>
<UiInput
v-if="editableUsers.length"
v-model="accessQuery"
placeholder="جستجوی کاربر…"
/>
</header>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
@@ -514,8 +553,9 @@ onMounted(() => {
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
@@ -567,6 +607,22 @@ onMounted(() => {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.card-head h3 {
margin: 0;
}
.card-head :deep(.field),
.card-head :deep(input) {
min-width: min(16rem, 100%);
max-width: 18rem;
}
.prior-head {
display: flex;
align-items: center;
@@ -722,5 +778,35 @@ select {
.prior-picker {
grid-template-columns: 1fr;
}
.card-head {
flex-direction: column;
align-items: stretch;
}
.card-head :deep(.field),
.card-head :deep(input) {
max-width: none;
min-width: 0;
width: 100%;
}
.team header {
flex-direction: column;
align-items: stretch;
}
.team .actions {
width: 100%;
}
.vis-row {
align-items: start;
}
.vis-badge {
display: inline-block;
margin: 0.25rem 0 0;
}
}
</style>
+214 -21
View File
@@ -1,20 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import { computed, ref, watch } from 'vue';
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
type PickUser = { id: string; fullName: string };
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
type AssigneeGroup = { id: string; name: string; userIds: string[] };
type GroupKind = 'role' | 'team';
const props = withDefaults(
defineProps<{
modelValue: string[];
users: PickUser[];
/** انتخاب یک نقش، همهٔ اعضایش را مسئول می‌کند */
roles?: AssigneeGroup[];
/** انتخاب یک تیم، همهٔ اعضایش را مسئول می‌کند */
teams?: AssigneeGroup[];
disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number;
addLabel?: string;
emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
{
roles: () => [],
teams: () => [],
disabled: false,
min: 0,
addLabel: 'افزودن',
emptyLabel: 'کسی انتخاب نشده',
selectAll: false,
},
);
const emit = defineEmits<{
@@ -22,32 +39,208 @@ const emit = defineEmits<{
change: [ids: string[]];
}>();
const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
const pinned = ref(new Set<string>());
function uniqueIds(ids: string[]) {
return [...new Set(ids.filter(Boolean))];
}
function normalizeSearch(value: string) {
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
}
const catalog = computed(() => {
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
for (const group of groups) {
const userIds = uniqueIds(group.userIds);
if (!userIds.length || !group.id || !group.name) continue;
rows.push({
token: `${kind}:${group.id}`,
name: group.name,
userIds,
kind,
});
}
};
push('role', props.roles);
push('team', props.teams);
return rows;
});
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
function covers(userIds: string[], selected: string[]) {
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
}
const shownGroups = computed(() =>
catalog.value.filter((group) => {
if (!covers(group.userIds, props.modelValue)) return false;
if (group.userIds.length >= 2) return true;
return pinned.value.has(group.token);
}),
);
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
const selectValue = computed(() => {
const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
return [
...shownGroups.value.map((group) => group.token),
...props.modelValue.filter((id) => !covered.has(id)),
];
});
const options = computed(() => {
const people = props.users.map((user) => ({
value: user.id,
label:
catalog.value.length && user.orgRoleName
? `${user.fullName} — ${user.orgRoleName}`
: user.fullName,
}));
if (!catalog.value.length) return people;
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
const section = (kind: GroupKind, title: string) => {
const items = catalog.value
.filter((group) => group.kind === kind)
.map((group) => ({
value: group.token,
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
}));
if (items.length) grouped.push({ label: title, options: items });
};
section('role', 'نقش‌ها');
section('team', 'تیم‌ها');
if (people.length) grouped.push({ label: 'اعضا', options: people });
return grouped;
});
function filterOption(input: string, option?: { label?: unknown }) {
const label = String(option?.label ?? '');
if (!label || !input.trim()) return true;
return normalizeSearch(label).includes(normalizeSearch(input));
}
const allPicked = computed(
() =>
props.users.length > 0 &&
props.users.every((user) => props.modelValue.includes(user.id)),
);
const tagLimit = computed(() => {
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
return undefined;
});
function tagPlaceholder(omitted: unknown[]) {
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
const extra = Array.isArray(omitted) ? omitted.length : 0;
if (!extra) return '';
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
}
watch(
() => props.modelValue.join('\0'),
() => {
const next = new Set<string>();
for (const token of pinned.value) {
const group = catalogByToken.value.get(token);
if (group && covers(group.userIds, props.modelValue)) next.add(token);
}
pinned.value = next;
},
);
function sameIds(left: string[], right: string[]) {
if (left.length !== right.length) return false;
const have = new Set(left);
return right.every((id) => have.has(id));
}
function commit(ids: string[]) {
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
emit('update:modelValue', ids);
emit('change', ids);
if (!sameIds(ids, props.modelValue)) emit('change', ids);
}
function onUpdate(value: unknown) {
const tokens = Array.isArray(value) ? value.map(String) : [];
const tokenSet = new Set(tokens);
const removedMemberIds = new Set<string>();
for (const token of selectValue.value) {
if (tokenSet.has(token)) continue;
const dropped = catalogByToken.value.get(token);
if (!dropped) continue;
for (const id of dropped.userIds) removedMemberIds.add(id);
}
const ids = new Set<string>();
const pins = new Set<string>();
for (const token of tokens) {
const group = catalogByToken.value.get(token);
if (group) {
pins.add(token);
for (const id of group.userIds) {
if (removedMemberIds.has(id)) continue;
ids.add(id);
}
continue;
}
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
}
const next = [...ids];
if (next.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
pinned.value = pins;
commit(next);
}
function pickAll() {
if (props.disabled || allPicked.value) return;
commit(props.users.map((user) => user.id));
}
</script>
<template>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
<div class="user-multi" :class="{ 'with-all': selectAll }">
<a-select
mode="multiple"
allow-clear
show-search
:value="selectValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
:filter-option="filterOption"
:max-tag-count="tagLimit"
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
style="width: 100%"
@update:value="onUpdate"
/>
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
همه
</a-button>
</div>
</template>
<style scoped>
.user-multi.with-all {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.user-multi.with-all :deep(.ant-select) {
flex: 1 1 auto;
min-width: 0;
}
.user-multi.with-all :deep(.ant-btn) {
flex: 0 0 auto;
}
</style>
@@ -9,9 +9,10 @@ import type {
} from '@mento/shared';
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
@@ -41,6 +42,7 @@ const emit = defineEmits<{
stagesChanged: [];
add: [stageId: string];
error: [message: string];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string];
bulkDelete: [];
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 });
const quickId = ref<string | null>(null);
const peekId = ref<string | null>(null);
let peekLinger = 0;
const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined,
@@ -88,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
function dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const due = new Date(item.dueAt);
const days = calendarDaysBetween(due, new Date());
const day =
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
const late =
item.status === 'open' &&
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length
@@ -112,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
date:
item.status === 'done'
? 'انجام شده'
: item.dueAt
? formatRelativeDayLabel(item.dueAt)
: 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title),
stageId,
createdAt: item.createdAt,
@@ -121,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
activityStatus: item.status,
};
}
@@ -222,12 +259,12 @@ function onMenuActivity(id: string) {
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
draggingColumnId.value = null;
// قفل/صف مانده از درخواست قبلی را باز کن
if (moving.value) resetMoveLock();
dragSessionId = deal.id;
// setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
@@ -235,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
}
function onDragEnd() {
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
clearDragState();
}, 0);
}
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -255,17 +322,19 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId) return;
const from = columns.value[fromStageId]!;
const from = columns.value[fromStageId];
const to = columns.value[toStageId];
if (!to) return;
if (!from || !to) return;
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);
if (!toStage) return;
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;
// بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return;
@@ -287,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
}
moving.value = true;
armMoveWatchdog();
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
position: insertAt,
});
if (!same && data.status !== toStage.kind) {
const hideFromMine = props.params.desk === 'mine';
from.items = hideFromMine
? prevFrom.filter((row) => row.id !== activityId)
: prevFrom.map((row) => (row.id === activityId ? data : row));
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
to.items = prevTo;
to.total = prevToTotal;
emit(
'notice',
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
);
emit('changed');
return;
}
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
@@ -304,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
window.clearTimeout(moveWatchdog);
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
dropStageId.value = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
const transfer = event.dataTransfer?.getData('text/plain') || '';
const cardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value;
clearDragState();
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
const activityId =
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return;
}
if (!raw || moving.value) return;
await moveCard(raw, stageId, index);
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
}
async function persistOrder(ids: string[]) {
@@ -404,7 +497,7 @@ async function onAddStage(name: string) {
}
}
function openActivity(id: string) {
function activityQuery(id: string) {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
@@ -418,13 +511,38 @@ function openActivity(id: string) {
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.priority) query.priority = source.priority;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
if (source.due) query.due = source.due;
const columnId = findStageOf(id);
if (columnId) query.stage = columnId;
void router.push({ name: 'activity-detail', params: { id }, query });
return query;
}
function openActivity(id: string) {
if (quickId.value === id) {
closeQuick();
return;
}
window.clearTimeout(peekLinger);
quickId.value = id;
peekId.value = id;
}
function closeQuick() {
quickId.value = null;
const id = peekId.value;
window.clearTimeout(peekLinger);
peekLinger = window.setTimeout(() => {
if (!quickId.value && peekId.value === id) peekId.value = null;
}, 650);
}
function openFullActivity(id: string) {
quickId.value = null;
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
}
</script>
@@ -440,23 +558,32 @@ function openActivity(id: string) {
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@detail-deal="openFullActivity"
@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"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -17,6 +17,8 @@ const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>();
const emit = defineEmits<{
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<div class="backdrop" aria-hidden="true" />
<section
class="sheet"
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
export type DeskFilterField =
| 'title'
| 'who'
| 'customer'
| 'type'
| 'priority'
| 'when'
| 'status';
const props = defineProps<{
/** یک ستون، یا پنل کامل بالای صفحه */
@@ -10,11 +17,13 @@ const props = defineProps<{
customer: string;
due: '' | 'set' | 'unset';
typeKey: string;
priorityKey: string;
statusKey: string;
assigneeId: string;
showAssignee: boolean;
users: Array<{ id: string; fullName: string }>;
types: Array<{ key: string; label: string }>;
priorities: Array<{ key: string; label: string; color?: string }>;
statuses: Array<{ key: string; label: string }>;
}>();
@@ -23,6 +32,7 @@ const emit = defineEmits<{
customer: [value: string];
due: [value: '' | 'set' | 'unset'];
type: [value: string];
priority: [value: string];
status: [value: string];
assignee: [value: string];
clear: [];
@@ -86,6 +96,7 @@ const hasAny = computed(
() =>
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
props.typeKey !== 'all' ||
props.priorityKey !== 'all' ||
props.statusKey !== 'all',
);
@@ -101,6 +112,9 @@ const whoLabel = computed(
const typeLabel = computed(
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
);
const priorityLabel = computed(
() => props.priorities.find((item) => item.key === props.priorityKey)?.label || 'همه',
);
const statusLabel = computed(
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
);
@@ -144,6 +158,11 @@ function pickType(next: string) {
finish();
}
function pickPriority(next: string) {
emit('priority', props.priorityKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickStatus(next: string) {
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
finish();
@@ -282,6 +301,36 @@ function pickAssignee(next: string) {
</div>
</section>
<section v-if="shows('priority')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: priorityKey !== 'all' }"
@click="toggleSection('priority')"
>
<span>اولویت</span>
<em>{{ priorityLabel }}</em>
</button>
<div v-if="opened('priority')" class="pick" role="listbox" aria-label="اولویت">
<button
v-for="opt in priorities"
:key="opt.key"
type="button"
:class="{ on: priorityKey === opt.key }"
@click="pickPriority(opt.key)"
>
<span
v-if="opt.color && opt.key !== 'all'"
class="prio-dot"
:style="{ background: opt.color }"
aria-hidden="true"
/>
{{ opt.label }}
</button>
</div>
</section>
<section v-if="shows('when')">
<button
v-if="field === 'all'"
@@ -434,6 +483,9 @@ function pickAssignee(next: string) {
}
.pick button {
display: flex;
align-items: center;
gap: 0.4rem;
text-align: start;
border-radius: 8px;
padding: 0.42rem 0.5rem;
@@ -450,6 +502,13 @@ function pickAssignee(next: string) {
font-weight: 700;
}
.prio-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex: none;
}
.seg {
display: flex;
gap: 0.25rem;
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
const props = defineProps<{
dueAt?: string | null;
saving?: boolean;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>();
const emit = defineEmits<{
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" />
@@ -0,0 +1,706 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Row as ARow,
Select as ASelect,
Image as AImage,
Spin as ASpin,
Typography,
theme,
} from 'ant-design-vue';
import type { ActivityDto } from '@mento/shared';
import { api } from '@/lib/api';
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
import { noteChips } from '@/lib/lead-form-notes';
import {
formatClockFa,
formatJalaliDay,
formatJalaliDayClock,
formatJalaliNumeric,
formatLocalDateTimeInput,
parseLocalInput,
startOfLocalDay,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
const props = defineProps<{
activityId: string | null;
}>();
const emit = defineEmits<{
close: [];
changed: [];
full: [id: string];
}>();
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
const { token } = theme.useToken();
const auth = useAuthStore();
const rendered = ref(false);
const placed = ref(false);
const panelEl = ref<HTMLElement | null>(null);
const frame = ref({ left: 12, top: 12, width: 440, maxHeight: 560, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
function cardEl(id: string) {
return document.querySelector<HTMLElement>(`.ant-card[data-card-id="${CSS.escape(id)}"]`);
}
function columnScroller(el: HTMLElement) {
let node = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
node = node.parentElement;
}
return null;
}
function panelExtent() {
const content = panelEl.value?.scrollHeight ?? 0;
return content > 80 ? content : 328;
}
/** Lift the column so a top-aligned panel fits. The last card may have no room left. */
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
const overflow = cardTop + height - (vh - VIEW_PAD);
if (overflow <= 8) return;
const scroller = columnScroller(el);
if (!scroller) return;
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
function measure() {
const id = props.activityId;
if (!id) return;
const el = cardEl(id);
if (!el) return;
let rect = el.getBoundingClientRect();
if (rect.width < 8) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const width = Math.min(440, vw - 24);
const gap = 10;
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
left = Math.max(12, Math.min(left, vw - width - 12));
const height = Math.min(panelExtent(), Math.max(160, vh - VIEW_PAD * 2));
if (reveal) {
reveal = false;
liftColumn(el, rect.top, height, vh);
rect = el.getBoundingClientRect();
}
let top = rect.top;
const maxTop = vh - VIEW_PAD - height;
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
if (top < VIEW_PAD) top = VIEW_PAD;
const maxHeight = Math.max(160, vh - VIEW_PAD - top);
const travel = Math.min(120, Math.round(width * 0.28));
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
}
const panelStyle = computed(() => ({
position: 'fixed' as const,
zIndex: 1050,
top: `${frame.value.top}px`,
left: `${frame.value.left}px`,
width: `${frame.value.width}px`,
maxHeight: `${frame.value.maxHeight}px`,
overflow: 'auto' as const,
boxSizing: 'border-box' as const,
padding: `${token.value.padding}px`,
background: token.value.colorBgElevated,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
opacity: placed.value ? 1 : 0,
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
}));
function onViewport() {
if (props.activityId) measure();
}
function onPointerDown(event: PointerEvent) {
if (!props.activityId || !placed.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (panelEl.value?.contains(target)) return;
if (commentOpen.value) return;
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
const card = cardEl(props.activityId);
if (card?.contains(target)) return;
emit('close');
}
function onKey(event: KeyboardEvent) {
if (event.key !== 'Escape' || !props.activityId) return;
if (commentOpen.value) return;
emit('close');
}
onMounted(() => {
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onViewport);
window.removeEventListener('scroll', onViewport, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.clearTimeout(hideTimer);
clearNoteMedia();
});
const activity = ref<ActivityDto | null>(null);
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const commentOpen = ref(false);
const postponeKey = ref('h1');
const customDue = ref('');
type NoteMedia = {
id: string;
name: string;
url: string;
kind: 'image' | 'audio' | 'file';
createdAt: string;
};
const noteMedia = ref<NoteMedia[]>([]);
function clearNoteMedia() {
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
noteMedia.value = [];
}
const isClosed = computed(
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
);
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
const canCollaborate = computed(() =>
canCollaborateOnActivity(activity.value, auth.user),
);
const needsEveryone = computed(
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
);
const myShareDone = computed(() => {
const mine = auth.user?.id;
return Boolean(mine && activity.value?.completedByIds?.includes(mine));
});
const actionLabel = computed(() => {
if (isClosed.value) return 'بازگشایی';
if (needsEveryone.value && myShareDone.value) return 'لغو انجام';
return 'انجام شده';
});
const createdLabel = computed(() => {
const raw = activity.value?.createdAt;
if (!raw) return '—';
const date = new Date(raw);
return `${formatJalaliDay(date)} - ${formatClockFa(date)}`;
});
const thread = computed(() => parseActivityThread(activity.value?.description));
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
const threadBlocks = computed(() => {
const entries = thread.value;
if (!entries) return null;
return bindThreadMedia(entries, noteMedia.value);
});
const looseImages = computed(() =>
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
);
const noteScrollStyle = computed(() => ({
maxHeight: '220px',
overflow: 'auto',
}));
const noteBubbleStyle = computed(() => ({
padding: '8px 12px',
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadius}px`,
background: token.value.colorFillAlter,
}));
const postponeBase = computed(() => {
const now = new Date();
const due = activity.value?.dueAt ? new Date(activity.value.dueAt) : now;
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
return due;
});
function addMinutes(mins: number) {
return new Date(postponeBase.value.getTime() + mins * 60_000);
}
function tomorrowMorning() {
const d = startOfLocalDay(new Date());
d.setDate(d.getDate() + 1);
d.setHours(10, 0, 0, 0);
return d;
}
function nextSaturdayMorning() {
const d = startOfLocalDay(new Date());
const untilSaturday = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
d.setHours(10, 0, 0, 0);
return d;
}
const postponeChoices = computed(() => {
const h1 = addMinutes(60);
const morning = tomorrowMorning();
const saturday = nextSaturdayMorning();
const week = new Date(postponeBase.value.getTime());
week.setDate(week.getDate() + 7);
return [
{ key: 'h1', label: `یک ساعت دیرتر · ${formatClockFa(h1)}`, at: h1 },
{ key: 'tomorrow-am', label: `فردا صبح · ${formatClockFa(morning)}`, at: morning },
{ key: 'next-saturday', label: `شنبه آینده · ${formatJalaliDayClock(saturday)}`, at: saturday },
{ key: 'next-week', label: `هفته آینده · ${formatJalaliDayClock(week)}`, at: week },
{ key: 'custom', label: 'زمان دلخواه', at: null },
];
});
const postponeReady = computed(() => {
if (postponeKey.value === 'custom') return Boolean(parseLocalInput(customDue.value));
return postponeChoices.value.some((item) => item.key === postponeKey.value && item.at);
});
function resetDrafts() {
commentOpen.value = false;
postponeKey.value = 'h1';
customDue.value = '';
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
async function loadNoteMedia(row: ActivityDto) {
clearNoteMedia();
const next: NoteMedia[] = [];
for (const image of row.images ?? []) {
const mime = image.mimeType.toLowerCase();
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
try {
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(data);
if (props.activityId !== row.id) {
URL.revokeObjectURL(url);
continue;
}
next.push({
id: image.id,
name: image.fileName,
url,
kind,
createdAt: image.createdAt,
});
} catch {
/* فایل در دسترس نیست */
}
}
if (props.activityId !== row.id) {
for (const item of next) URL.revokeObjectURL(item.url);
return;
}
noteMedia.value = next;
await nextTick();
measure();
}
async function load(id: string) {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ActivityDto>(`/activities/${id}`);
if (props.activityId !== id) return;
activity.value = data;
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
void loadNoteMedia(data);
} catch (e) {
if (props.activityId === id) {
activity.value = null;
error.value = apiMessage(e, 'بارگذاری فعالیت ناموفق بود.');
}
} finally {
if (props.activityId === id) {
loading.value = false;
reveal = true;
await nextTick();
measure();
}
}
}
watch(
() => props.activityId,
async (id, prev) => {
if (!id) {
activity.value = null;
error.value = '';
resetDrafts();
clearNoteMedia();
placed.value = false;
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
if (!props.activityId) rendered.value = false;
}, 230);
return;
}
window.clearTimeout(hideTimer);
if (prev && prev !== id) {
resetDrafts();
activity.value = null;
clearNoteMedia();
}
if (prev && prev !== id && rendered.value) placed.value = false;
rendered.value = true;
measure();
await nextTick();
measure();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (props.activityId === id) placed.value = true;
});
});
void load(id);
},
{ immediate: true },
);
async function runAction() {
const id = props.activityId;
const row = activity.value;
if (!id || !row || saving.value) return;
saving.value = true;
error.value = '';
try {
if (isClosed.value) {
if (!canMutate.value) {
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
return;
}
await api.patch(`/activities/${id}`, { status: 'open' });
await load(id);
emit('changed');
return;
}
const undo = needsEveryone.value && myShareDone.value;
const { data } = await api.patch<ActivityDto>(`/activities/${id}/complete`, undefined, {
params: undo ? { undo: '1' } : undefined,
});
if (data.status === 'done') {
emit('changed');
emit('close');
return;
}
activity.value = data;
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ثبت تغییر ناموفق بود.');
} finally {
saving.value = false;
}
}
async function postponeTo(iso: string) {
const id = props.activityId;
if (!id || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id}`, { dueAt: iso });
await load(id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'تعویق فعالیت ناموفق بود.');
} finally {
saving.value = false;
}
}
async function submitComment(payload: { text: string; photoOnly: boolean }) {
const id = props.activityId;
const row = activity.value;
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
if (!id || !row || !body) return;
saving.value = true;
error.value = '';
try {
const prev = row.description?.trim();
const now = new Date();
const author = auth.user?.fullName?.trim();
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
const entry = `${stamp}\n${body}`;
await api.patch(`/activities/${id}`, { description: prev ? `${prev}\n\n${entry}` : entry });
commentOpen.value = false;
await load(id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ثبت نظر ناموفق بود.');
throw e;
} finally {
saving.value = false;
}
}
async function applyPostpone() {
if (postponeKey.value === 'custom') {
const at = parseLocalInput(customDue.value);
if (!at) return;
await postponeTo(at.toISOString());
return;
}
const at = postponeChoices.value.find((item) => item.key === postponeKey.value)?.at;
if (at) await postponeTo(at.toISOString());
}
function openFull() {
if (!props.activityId) return;
emit('full', props.activityId);
}
</script>
<template>
<Teleport to="body">
<div
v-if="rendered"
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="activity?.title || 'جزئیات فعالیت'"
:style="panelStyle"
>
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
<a-button type="text" aria-label="بستن" @click="emit('close')">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<a-spin :spinning="loading">
<a-alert v-if="error" type="error" :message="error" show-icon :style="{ marginBottom: '12px' }" />
<template v-if="activity">
<a-row :gutter="[16, 12]">
<a-col :xs="24" :sm="12">
<a-flex vertical :gap="2">
<a-typography-text type="secondary">ایجاد شده توسط</a-typography-text>
<a-typography-text>{{ activity.createdBy?.fullName || '—' }}</a-typography-text>
</a-flex>
</a-col>
<a-col :xs="24" :sm="12">
<a-flex vertical :gap="2">
<a-typography-text type="secondary">تاریخ ایجاد</a-typography-text>
<a-typography-text>{{ createdLabel }}</a-typography-text>
</a-flex>
</a-col>
</a-row>
<a-divider :style="{ margin: '16px 0 12px' }" />
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
<a-typography-text strong>یادداشت</a-typography-text>
<a-button
type="primary"
shape="circle"
aria-label="یادداشت جدید"
:disabled="!canCollaborate"
@click="commentOpen = true"
>
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
<div class="note-photos" :style="noteScrollStyle">
<a-flex v-if="threadBlocks" vertical :gap="8">
<div
v-for="(entry, index) in threadBlocks.blocks"
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
:style="noteBubbleStyle"
>
<a-flex v-if="entry.dateLabel" justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">
{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}
</a-typography-text>
<a-typography-text v-if="entry.author" strong :style="{ color: token.colorPrimary }">
{{ entry.author }}
</a-typography-text>
</a-flex>
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
<a-image
v-if="chip.kind === 'image'"
:src="chip.src"
:alt="chip.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
:style="{ display: 'block', marginTop: '8px' }"
/>
<a-typography-link
v-else-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>
{{ chip.label }}
</a-typography-link>
<span v-else>{{ chip.text }}</span>
</template>
</a-typography-paragraph>
<a-flex
v-if="entry.media.some((item) => item.kind === 'image')"
wrap="wrap"
:gap="8"
:style="{ marginTop: '8px' }"
>
<a-image
v-for="image in entry.media.filter((item) => item.kind === 'image')"
:key="image.id"
:src="image.url"
:alt="image.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
/>
</a-flex>
</div>
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
<a-image :src="image.url" :alt="image.name" :width="72"
:height="72" :preview="{ zIndex: 1300 }" />
</div>
</a-flex>
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
<a-image
v-if="chip.kind === 'image'"
:src="chip.src"
:alt="chip.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
:style="{ display: 'block', marginTop: '8px' }"
/>
<a-typography-link
v-else-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>
{{ chip.label }}
</a-typography-link>
<span v-else>{{ chip.text }}</span>
</template>
</a-typography-paragraph>
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
<a-image
v-for="image in looseImages"
:key="image.id"
:src="image.url"
:alt="image.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
/>
</a-flex>
</div>
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
</div>
<a-flex vertical :gap="8" :style="{ marginTop: '12px' }">
<a-flex align="center" :gap="8">
<a-select
v-model:value="postponeKey"
:disabled="saving || loading || isClosed || !canCollaborate"
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
:dropdown-style="{ zIndex: 1200 }"
style="flex: 1"
/>
<a-button
type="primary"
:disabled="saving || loading || isClosed || !canCollaborate || !postponeReady"
@click="applyPostpone"
>
تعویق
</a-button>
</a-flex>
<JalaliDateInput
v-if="postponeKey === 'custom'"
v-model="customDue"
mode="datetime"
placeholder="تاریخ و ساعت"
:disabled="!canCollaborate"
/>
</a-flex>
<a-divider :style="{ margin: '16px 0 12px' }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-button
type="primary"
:disabled="saving || loading || (isClosed && !canMutate)"
@click="runAction"
>
{{ actionLabel }}
</a-button>
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
</a-flex>
</template>
</a-spin>
<ActivityCommentSheet
v-if="commentOpen && activity"
:activity="activity"
:saving="saving"
:overlay-z-index="1200"
:save="submitComment"
@close="commentOpen = false"
/>
</div>
</Teleport>
</template>
<style scoped>
.note-photos :deep(.ant-image) {
width: 72px;
height: 72px;
}
.note-photos :deep(.ant-image-img) {
width: 72px;
height: 72px;
object-fit: cover;
}
</style>
@@ -4,6 +4,7 @@ import axios from 'axios';
import type {
ActivityFieldDef,
ActivityFieldType,
ActivityPrioritySetting,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
@@ -17,9 +18,11 @@ import {
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ACTIVITY_TYPE_ICON_LABELS,
newFieldKey,
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
type MgrTab = 'types' | 'priorities';
const tab = ref<MgrTab>('types');
const prioritiesApi = useActivityPriorities();
const priorityDraft = ref<ActivityPrioritySetting[]>(
DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })),
);
const prioritySaved = ref('');
const priorityDirty = computed(
() => JSON.stringify(priorityDraft.value) !== prioritySaved.value,
);
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string };
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
notice.value = '';
}
async function loadPrioritiesTab() {
await prioritiesApi.load(true);
priorityDraft.value = prioritiesApi.items.value.map((r) => ({ ...r }));
prioritySaved.value = JSON.stringify(priorityDraft.value);
}
function switchTab(next: MgrTab) {
if (next === tab.value) return;
if (tab.value === 'types' && dirty.value && draft.value) {
if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return;
}
if (tab.value === 'priorities' && priorityDirty.value) {
if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return;
}
tab.value = next;
error.value = '';
notice.value = '';
if (next === 'priorities') void loadPrioritiesTab();
}
async function savePriorities() {
for (const row of priorityDraft.value) {
if (!row.label.trim()) {
error.value = 'عنوان همهٔ سطوح اولویت را بنویسید.';
return;
}
if (!/^#[0-9a-fA-F]{6}$/.test(row.color)) {
error.value = 'رنگ باید به صورت #RRGGBB باشد.';
return;
}
}
busy.value = 'prio';
error.value = '';
notice.value = '';
try {
const { data } = await api.put<ActivityPrioritySetting[]>('/activities/priorities', {
items: priorityDraft.value.map((r) => ({
key: r.key,
label: r.label.trim(),
color: r.color,
})),
});
prioritiesApi.setLocal(data);
priorityDraft.value = data.map((r) => ({ ...r }));
prioritySaved.value = JSON.stringify(priorityDraft.value);
notice.value = 'اولویت‌ها ذخیره شد.';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ اولویت‌ها ناموفق بود.');
} finally {
busy.value = '';
}
}
function resetPriorities() {
if (!window.confirm('عنوان و رنگ‌ها به پیش‌فرض برگردند؟')) return;
priorityDraft.value = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r }));
}
watch(
() => props.open,
async (open) => {
if (!open) return;
error.value = '';
notice.value = '';
tab.value = 'types';
await load(true);
await prioritiesApi.load(true);
const first = types.value[0];
selectedId.value = null;
draft.value = null;
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
}
function close() {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
if (tab.value === 'types' && dirty.value && draft.value) {
if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
}
if (tab.value === 'priorities' && priorityDirty.value) {
if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. بستن؟')) return;
}
emit('close');
}
</script>
@@ -291,6 +371,28 @@ function close() {
<template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr">
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: tab === 'types' }"
:aria-selected="tab === 'types'"
@click="switchTab('types')"
>
انواع و فیلدها
</button>
<button
type="button"
role="tab"
:class="{ on: tab === 'priorities' }"
:aria-selected="tab === 'priorities'"
@click="switchTab('priorities')"
>
اولویت‌ها
</button>
</div>
<template v-if="tab === 'types'">
<p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
@@ -479,6 +581,43 @@ function close() {
</section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div>
</template>
<div v-else class="prio">
<p class="hint">
پنج سطح اولویت ثابت است؛ فقط عنوان و رنگ را عوض کنید. تغییر عنوان روی همهٔ فعالیت‌ها و کارهای
روتین قبلی هم اعمال می‌شود (کلید داخلی عوض نمی‌شود).
</p>
<ul class="prio-list">
<li v-for="row in priorityDraft" :key="row.key" class="prio-row">
<span class="swatch" :style="{ background: row.color }" aria-hidden="true" />
<label class="f grow">
<span>عنوان</span>
<input v-model="row.label" maxlength="40" />
</label>
<label class="f color">
<span>رنگ</span>
<input v-model="row.color" type="color" :aria-label="`رنگ ${row.label}`" />
</label>
</li>
</ul>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="actions">
<UiButton
variant="primary"
type="button"
:disabled="!priorityDirty || Boolean(busy)"
:loading="busy === 'prio'"
@click="savePriorities"
>
ذخیرهٔ اولویت‌ها
</UiButton>
<UiButton variant="ghost" type="button" :disabled="Boolean(busy)" @click="resetPriorities">
بازگشت به پیش‌فرض
</UiButton>
</div>
</div>
</div>
</UiModal>
</template>
@@ -489,6 +628,78 @@ function close() {
gap: 0.9rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tabs button {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.85rem;
font: inherit;
font-size: 0.84rem;
cursor: pointer;
color: #475569;
}
.tabs button.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.prio {
display: grid;
gap: 0.85rem;
}
.prio-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.prio-row {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.swatch {
width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
margin-bottom: 0.55rem;
flex-shrink: 0;
border: 1px solid rgba(15, 23, 42, 0.12);
}
.prio-row .grow {
flex: 1;
min-width: 10rem;
}
.prio-row .color input[type='color'] {
width: 3rem;
height: 2.1rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: none;
cursor: pointer;
}
.hint {
margin: 0;
color: #64748b;
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
LeftOutlined,
PhoneOutlined,
PlusOutlined,
ScheduleOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
const { token } = theme.useToken();
const STATUS_LABELS: Record<ActivityStatus, string> = {
open: 'باز',
done: 'انجام‌شده',
canceled: 'لغو شده',
};
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
call: PhoneOutlined,
meeting: TeamOutlined,
task: CheckCircleOutlined,
note: ScheduleOutlined,
};
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
const props = withDefaults(
defineProps<{
dealId: string;
personId?: string | null;
companyId?: string | null;
dealTitle?: string;
}>(),
{
personId: null,
companyId: null,
dealTitle: '',
},
);
const router = useRouter();
const items = ref<ActivityDto[]>([]);
const loading = ref(false);
const error = ref('');
const closedKeys = ref<string[]>([]);
function sortByDue(a: ActivityDto, b: ActivityDto) {
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
}
const planned = computed(() =>
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
);
const closed = computed(() =>
items.value
.filter((a) => a.status !== 'open')
.slice()
.sort((a, b) => {
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
return tb.localeCompare(ta);
}),
);
function dueTone(dueAt: string | null): DueTone {
if (!dueAt) return 'none';
const due = new Date(dueAt);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
if (days < 0) return 'overdue';
if (days === 0) return 'today';
if (days <= 2) return 'soon';
return 'later';
}
function dueLabel(dueAt: string | null) {
if (!dueAt) return 'بدون موعد';
const d = new Date(dueAt);
if (Number.isNaN(d.getTime())) return 'بدون موعد';
const tone = dueTone(dueAt);
if (tone === 'overdue') {
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
}
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
}
function dueTagColor(tone: DueTone) {
if (tone === 'overdue') return 'error';
if (tone === 'today') return 'warning';
if (tone === 'soon') return 'processing';
return 'default';
}
function rowStyle(tone: DueTone, closedRow = false) {
const base = {
width: '100%',
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
cursor: 'pointer' as const,
opacity: closedRow ? 0.78 : 1,
};
if (closedRow) {
return { ...base, background: token.value.colorFillAlter };
}
if (tone === 'overdue') {
return {
...base,
borderColor: token.value.colorErrorBorder,
background: token.value.colorErrorBg,
};
}
if (tone === 'today') {
return {
...base,
borderColor: token.value.colorWarningBorder,
background: token.value.colorWarningBg,
};
}
return base;
}
function avatarStyle(tone: DueTone) {
if (tone === 'overdue') {
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
}
if (tone === 'today') {
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
}
return {
backgroundColor: token.value.colorPrimaryBg,
color: token.value.colorPrimary,
};
}
function typeLabel(item: ActivityDto) {
const type = item.type as ActivityType;
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
}
function statusLabel(status: ActivityDto['status']) {
return STATUS_LABELS[status as ActivityStatus];
}
function typeIcon(type: ActivityDto['type']) {
return TYPE_ICONS[type as ActivityType];
}
function newActivityQuery() {
const query: Record<string, string> = { dealId: props.dealId };
if (props.personId) query.personId = props.personId;
if (props.companyId) query.companyId = props.companyId;
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
return query;
}
function goCreate() {
void router.push({ name: 'activity-new', query: newActivityQuery() });
}
function goDetail(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
}
async function load() {
if (!props.dealId) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', dealId: props.dealId, limit: 50 },
});
items.value = data.items ?? [];
} catch {
error.value = 'بارگذاری فعالیت‌های معامله ممکن نشد.';
items.value = [];
} finally {
loading.value = false;
}
}
watch(
() => props.dealId,
() => {
void load();
},
{ immediate: true },
);
</script>
<template>
<a-card :bordered="false">
<template #title>
<a-space :size="8">
<CalendarOutlined :style="{ color: token.colorPrimary }" />
<span>فعالیت‌های برنامه‌ریزی‌شده</span>
<a-tag v-if="planned.length" color="processing" :bordered="false">
{{ toFaDigits(planned.length) }} باز
</a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن فعالیت
</a-button>
</template>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginBottom: `${token.margin}px` }"
/>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !planned.length"
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
:style="{ paddingBlock: `${token.paddingLG}px` }"
>
<template #description>
<a-flex vertical align="center" :gap="token.marginXS">
<a-typography-title :level="5" :style="{ margin: 0 }">
هنوز فعالیتی برای این معامله نیست
</a-typography-title>
<a-typography-paragraph
type="secondary"
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
>
یک تماس، جلسه یا پیگیری زمان‌بندی کن تا معامله از دست نرود.
</a-typography-paragraph>
</a-flex>
</template>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن اولین فعالیت
</a-button>
</a-empty>
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
<a-flex
v-for="item in planned"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle(dueTone(item.dueAt))"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
<template #icon>
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
<component :is="typeIcon(item.type)" v-else />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
{{ dueLabel(item.dueAt) }}
</a-tag>
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
{{ item.assignee.fullName }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
<template v-if="closed.length">
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
<a-collapse
v-model:activeKey="closedKeys"
ghost
:bordered="false"
:style="{
background: 'transparent',
marginTop: planned.length ? 0 : `${token.marginSM}px`,
}"
>
<a-collapse-panel key="closed">
<template #header>
<a-typography-text type="secondary">
{{ toFaDigits(closed.length) }} فعالیت انجام‌شده یا لغو شده
</a-typography-text>
</template>
<a-flex vertical :gap="token.marginSM">
<a-flex
v-for="item in closed"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle('none', true)"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex
align="center"
:gap="token.marginSM"
:style="{ minWidth: 0, flex: '1 1 220px' }"
>
<a-avatar
:size="40"
:style="{
backgroundColor: token.colorFillSecondary,
color: token.colorTextSecondary,
}"
>
<template #icon>
<component :is="typeIcon(item.type)" />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
<a-typography-text type="secondary">
{{ dueLabel(item.dueAt) }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
</a-collapse-panel>
</a-collapse>
</template>
</a-spin>
</a-card>
</template>
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { hasSectionPermission } from '@/lib/section-permissions';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
@@ -25,9 +27,13 @@ type BirthdayMonth = {
items: BirthdayItem[];
};
const auth = useAuthStore();
const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null);
const canSeeCustomers = computed(() =>
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
);
const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>();
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
}
onMounted(async () => {
if (!canSeeCustomers.value) {
data.value = null;
return;
}
try {
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
data.value = body.total > 0 ? body : null;
+189 -51
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
LeftOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -18,10 +19,12 @@ import {
Typography,
theme,
} from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
const props = withDefaults(
defineProps<{
@@ -31,6 +34,9 @@ const props = withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selected?: boolean;
peeking?: boolean;
/** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */
previewing?: boolean;
}>(),
{ copy: () => DEAL_BOARD_COPY },
);
@@ -39,6 +45,7 @@ const emit = defineEmits<{
dragStart: [deal: PipelineDeal, event: DragEvent];
dragEnd: [];
open: [dealId: string];
detail: [dealId: string];
menu: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -47,6 +54,11 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const activityOpen = ref(false);
const highlighted = computed(
() => props.selected || props.peeking || activityOpen.value,
);
const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null,
);
@@ -63,26 +75,97 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#eab308' },
low: { label: 'کم', color: '#16a34a' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const dateHint = computed(() => {
if (!props.deal.dateIso) return props.deal.date;
const d = new Date(props.deal.dateIso);
if (Number.isNaN(d.getTime())) return props.deal.date;
return formatJalaliNumeric(d);
});
const activityPriorities = useActivityPriorities();
onMounted(() => {
void activityPriorities.load();
});
const priorityFlag = computed(() => {
const key = props.deal.priority;
if (!key) return null;
return {
label: activityPriorities.labelOf(key),
color: activityPriorities.colorOf(key),
};
});
function onTitleClick() {
if (props.selectMode) {
emit('toggleSelect', props.deal.id);
return;
}
if (props.copy.quickView) emit('detail', props.deal.id);
else emit('open', props.deal.id);
}
/** موعد: (فقط فعالیت) انجام‌شده سبز · عقب‌افتاده قرمز · امروز زرد · آینده/بی‌موعد خاکستری */
const dueTone = computed<'done' | 'overdue' | 'today' | 'upcoming' | 'none'>(() => {
if (props.copy.quickView && props.deal.activityStatus === 'done') return 'done';
if (!props.deal.dateIso) return 'none';
const due = new Date(props.deal.dateIso);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(new Date(), due);
if (days < 0) return 'overdue';
if (days === 0) return 'today';
return 'upcoming';
});
const dueToneColor = computed(() => {
if (dueTone.value === 'done') return token.value.colorSuccess;
if (dueTone.value === 'overdue') return token.value.colorError;
if (dueTone.value === 'today') return token.value.colorWarning;
return token.value.colorTextSecondary;
});
const chevronBtnStyle = computed(() => ({
boxSizing: 'border-box' as const,
width: '22px',
height: '22px',
margin: 0,
padding: 0,
border: `1.5px solid ${dueToneColor.value}`,
borderRadius: '50%',
background: 'transparent',
color: dueToneColor.value,
display: 'inline-grid',
placeItems: 'center',
cursor: 'pointer',
lineHeight: 0,
flexShrink: 0,
}));
const chevronIconStyle = computed(() => ({
fontSize: '10px',
width: '10px',
height: '10px',
lineHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: props.previewing ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s ease',
}));
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو می‌کند؛ فقط سایه
opacity: props.dragging ? 0.55 : 1,
transition: 'opacity 120ms ease, box-shadow 120ms ease',
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
position: highlighted.value ? 'relative' : undefined,
zIndex: highlighted.value ? 2 : undefined,
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: highlighted.value
? `0 0 0 2px ${token.value.colorPrimary}`
: props.dragging
? token.value.boxShadow
: token.value.boxShadowTertiary,
}));
</script>
@@ -124,21 +207,29 @@ const cardStyle = computed(() => ({
</template>
</a-button>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
ellipsis
:style="{
display: 'block',
margin: 0,
lineHeight: 1.35,
cursor: 'pointer',
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
}"
:title="deal.title"
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
>
{{ deal.title }}
</a-typography-text>
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
</a-tooltip>
<a-typography-text
ellipsis
:style="{
flex: 1,
minWidth: 0,
display: 'block',
margin: 0,
lineHeight: 1.35,
cursor: 'pointer',
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
}"
:title="deal.title"
@click.stop="onTitleClick"
@dblclick.stop
>
{{ deal.title }}
</a-typography-text>
</a-flex>
<a-typography-text
v-if="deal.description"
type="secondary"
@@ -193,28 +284,75 @@ const cardStyle = computed(() => ({
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }}
</a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
<a-tooltip :title="dateHint">
<a-typography-text
:type="dueTone === 'none' ? 'secondary' : undefined"
:style="{
fontSize: `${token.fontSizeSM}px`,
color: dueTone !== 'none' ? dueToneColor : undefined,
}"
>
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-tooltip>
</a-space>
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap' }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</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' }" />
<button
v-if="copy.quickView && !selectMode"
type="button"
class="deal-card-peek"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing"
:style="chevronBtnStyle"
@click.stop="emit('open', deal.id)"
>
<LeftOutlined :style="chevronIconStyle" />
</button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
<span
v-if="copy.quickActivity && !selectMode"
@click.stop
@mousedown.stop
>
<DealQuickActivityPopover
:deal="deal"
@update:open="activityOpen = $event"
/>
</span>
</a-tooltip>
</a-space>
</a-flex>
</a-flex>
</a-card>
</template>
<style scoped>
/* وسط‌چین دقیق آیکون داخل دایره (anticon پیش‌فرض vertical-align دارد) */
.deal-card-peek :deep(.anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
vertical-align: 0;
line-height: 0;
}
.deal-card-peek :deep(.anticon > svg) {
display: block;
width: 10px;
height: 10px;
}
</style>
@@ -0,0 +1,571 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Badge as ABadge,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
CloseOutlined,
FormOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import type {
ActivityDto,
ActivityType,
CreateActivityRequest,
DealActivityCue,
DealActivitySummaryDto,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import {
formatLocalDateInput,
formatRelativeDayLabel,
formatClockFa,
toFaDigits,
} from '@/lib/jalali';
import type { PipelineDeal } from '@/lib/pipeline-board';
const { Title: ATypographyTitle, Link: ATypographyLink, Text: ATypographyText } =
Typography;
const props = defineProps<{
deal: PipelineDeal;
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:open': [open: boolean];
}>();
const router = useRouter();
const { token } = theme.useToken();
type PanelMode = 'list' | 'create';
const open = ref(false);
const rendered = ref(false);
const placed = ref(false);
const panelEl = ref<HTMLElement | null>(null);
const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
const PREVIEW_LIMIT = 3;
const mode = ref<PanelMode>('create');
const listExpanded = ref(false);
const saving = ref(false);
const error = ref('');
const title = ref('');
const type = ref<ActivityType>('task');
const dueDate = ref('');
const dueTime = ref('');
const localCue = ref<DealActivityCue | null>(null);
const localSummaries = ref<DealActivitySummaryDto[] | null>(null);
const summaries = computed(
() => localSummaries.value ?? props.deal.activitySummaries ?? [],
);
const activityCue = computed<DealActivityCue>(
() => localCue.value ?? props.deal.activityCue ?? 'none',
);
const openCount = computed(
() => summaries.value.filter((item) => item.status === 'open').length,
);
const visibleSummaries = computed(() =>
listExpanded.value ? summaries.value : summaries.value.slice(0, PREVIEW_LIMIT),
);
const hiddenCount = computed(() =>
Math.max(0, summaries.value.length - PREVIEW_LIMIT),
);
const cueMeta = computed(() => {
switch (activityCue.value) {
case 'overdue':
return { color: token.value.colorError, title: 'فعالیت عقب‌افتاده' };
case 'done':
return { color: token.value.colorSuccess, title: 'فعالیت انجام‌شده' };
case 'none':
return { color: token.value.colorWarning, title: 'بدون فعالیت — افزودن' };
default:
return { color: token.value.colorTextSecondary, title: 'فعالیت‌های معامله' };
}
});
const typeOptions = computed(() =>
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
.filter(([value]) => value !== 'note')
.map(([value, label]) => ({ value, label })),
);
const fieldItemStyle = computed(() => ({
marginBottom: 0,
width: '100%',
}));
const panelTitle = computed(() =>
mode.value === 'list' ? 'فعالیت‌های معامله' : 'ایجاد فعالیت سریع',
);
watch(
() => [props.deal.activityCue, props.deal.activitySummaries] as const,
() => {
localCue.value = null;
localSummaries.value = null;
},
);
function resetForm() {
title.value = `پیگیری: ${props.deal.title}`;
type.value = 'task';
dueDate.value = formatLocalDateInput(new Date());
dueTime.value = '';
error.value = '';
}
function cueDotColor(cue: Exclude<DealActivityCue, 'none'>) {
if (cue === 'overdue') return token.value.colorError;
if (cue === 'done') return token.value.colorSuccess;
return token.value.colorTextSecondary;
}
function activityWhen(item: DealActivitySummaryDto) {
if (!item.dueAt) return 'بدون موعد';
const d = new Date(item.dueAt);
if (Number.isNaN(d.getTime())) return '—';
const day = formatRelativeDayLabel(item.dueAt);
return `${day} · ${formatClockFa(d)}`;
}
function cardEl() {
return document.querySelector<HTMLElement>(
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
);
}
function columnScroller(el: HTMLElement) {
let node = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
node = node.parentElement;
}
return null;
}
function panelExtent() {
const content = panelEl.value?.scrollHeight ?? 0;
return content > 80 ? content : 360;
}
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
const overflow = cardTop + height - (vh - VIEW_PAD);
if (overflow <= 8) return;
const scroller = columnScroller(el);
if (!scroller) return;
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
function measure() {
const el = cardEl();
if (!el) return;
let rect = el.getBoundingClientRect();
if (rect.width < 8) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const width = Math.min(360, vw - 24);
const gap = 10;
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
left = Math.max(12, Math.min(left, vw - width - 12));
const height = Math.min(panelExtent(), Math.max(200, vh - VIEW_PAD * 2));
if (reveal) {
reveal = false;
liftColumn(el, rect.top, height, vh);
rect = el.getBoundingClientRect();
}
let top = rect.top;
const maxTop = vh - VIEW_PAD - height;
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
if (top < VIEW_PAD) top = VIEW_PAD;
const maxHeight = Math.max(200, vh - VIEW_PAD - top);
const travel = Math.min(100, Math.round(width * 0.28));
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
}
const panelStyle = computed(() => ({
position: 'fixed' as const,
zIndex: 1050,
top: `${frame.value.top}px`,
left: `${frame.value.left}px`,
width: `${frame.value.width}px`,
maxHeight: `${frame.value.maxHeight}px`,
overflow: 'auto' as const,
boxSizing: 'border-box' as const,
padding: `${token.value.padding}px`,
background: token.value.colorBgElevated,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
opacity: placed.value ? 1 : 0,
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
}));
function setOpen(next: boolean) {
if (open.value === next) return;
open.value = next;
emit('update:open', next);
}
function toggleOpen() {
if (props.disabled) return;
setOpen(!open.value);
}
function close() {
setOpen(false);
}
function openCreate() {
mode.value = 'create';
resetForm();
void nextTick().then(() => measure());
}
function backToList() {
mode.value = 'list';
error.value = '';
void nextTick().then(() => measure());
}
function goActivity(id: string) {
close();
void router.push({ name: 'activity-detail', params: { id } });
}
function onViewport() {
if (open.value) measure();
}
function onPointerDown(event: PointerEvent) {
if (!open.value || !placed.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (panelEl.value?.contains(target)) return;
if (
target instanceof Element &&
target.closest('.ant-select-dropdown, #jalali-picker, .layer')
) {
return;
}
const card = cardEl();
if (card?.contains(target)) return;
close();
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && open.value) close();
}
watch(open, async (isOpen) => {
if (!isOpen) {
placed.value = false;
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
if (!open.value) rendered.value = false;
}, 230);
return;
}
window.clearTimeout(hideTimer);
listExpanded.value = false;
mode.value = summaries.value.length ? 'list' : 'create';
resetForm();
rendered.value = true;
reveal = true;
measure();
await nextTick();
measure();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (open.value) {
placed.value = true;
void nextTick().then(() => measure());
}
});
});
});
onMounted(() => {
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onViewport);
window.removeEventListener('scroll', onViewport, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.clearTimeout(hideTimer);
});
function dueAtIso(): string | null {
if (!dueDate.value) return null;
const time = dueTime.value.trim() || '10:30';
const due = new Date(`${dueDate.value}T${time}:00`);
return Number.isNaN(due.getTime()) ? null : due.toISOString();
}
function fullFormQuery() {
const query: Record<string, string> = { dealId: props.deal.id };
if (props.deal.personId) query.personId = props.deal.personId;
if (props.deal.companyId) query.companyId = props.deal.companyId;
if (title.value.trim()) query.title = title.value.trim();
if (type.value) query.type = type.value;
if (dueAtIso()) query.dueAt = dueAtIso()!;
return query;
}
function goFullForm() {
close();
void router.push({ name: 'activity-new', query: fullFormQuery() });
}
async function submit() {
error.value = '';
if (title.value.trim().length < 2) {
error.value = 'عنوان فعالیت را بنویسید.';
return;
}
const dueAt = dueAtIso();
if (!dueDate.value || !dueAt) {
error.value = 'تاریخ فعالیت را مشخص کنید.';
return;
}
const body: CreateActivityRequest = {
title: title.value.trim(),
type: type.value,
dueAt,
dealId: props.deal.id,
personId: props.deal.personId || null,
companyId: props.deal.companyId || null,
assigneeId: props.deal.ownerId || null,
};
saving.value = true;
try {
const { data } = await api.post<ActivityDto>('/activities', body);
message.success('فعالیت ثبت شد');
const dueMs = new Date(dueAt).getTime();
const cue: Exclude<DealActivityCue, 'none'> =
Number.isFinite(dueMs) && dueMs < Date.now() ? 'overdue' : 'upcoming';
const created: DealActivitySummaryDto = {
id: data.id,
title: data.title,
type: data.type,
status: data.status === 'done' ? 'done' : 'open',
dueAt: data.dueAt,
cue,
};
const next = [created, ...summaries.value.filter((item) => item.id !== created.id)];
localSummaries.value = next;
localCue.value =
next.some((item) => item.cue === 'overdue')
? 'overdue'
: next.some((item) => item.status === 'open')
? 'upcoming'
: next.some((item) => item.cue === 'done')
? 'done'
: 'none';
mode.value = 'list';
void nextTick().then(() => measure());
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
</script>
<template>
<a-tooltip :title="cueMeta.title">
<a-badge
:count="openCount"
:offset="[-2, 2]"
:number-style="{ fontSize: '10px', minWidth: '16px', height: '16px', lineHeight: '16px' }"
>
<a-button
type="text"
:disabled="disabled"
aria-label="فعالیت‌های معامله"
:aria-expanded="open"
:style="{
color: open ? token.colorPrimary : cueMeta.color,
width: '28px',
minWidth: '28px',
height: '28px',
padding: 0,
}"
@click.stop="toggleOpen"
@mousedown.stop
@dragstart.stop.prevent
>
<template #icon><CalendarOutlined /></template>
</a-button>
</a-badge>
</a-tooltip>
<Teleport to="body">
<div
v-if="rendered"
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="`${panelTitle} برای ${deal.title}`"
:style="panelStyle"
>
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
<a-typography-title :level="5" :style="{ margin: 0 }">{{ panelTitle }}</a-typography-title>
<a-space :size="4">
<a-button
v-if="mode === 'list'"
type="text"
aria-label="فعالیت جدید"
@click="openCreate"
>
<template #icon><PlusOutlined /></template>
</a-button>
<a-button type="text" aria-label="بستن" @click="close">
<template #icon><CloseOutlined /></template>
</a-button>
</a-space>
</a-flex>
<template v-if="mode === 'list'">
<a-flex vertical :gap="8">
<a-card
v-for="item in visibleSummaries"
:key="item.id"
hoverable
:bordered="true"
:body-style="{ padding: '10px 12px' }"
@click="goActivity(item.id)"
>
<a-flex align="flex-start" :gap="10">
<span
aria-hidden="true"
:style="{
width: '8px',
height: '8px',
marginTop: '6px',
borderRadius: '999px',
background: cueDotColor(item.cue),
flexShrink: 0,
}"
/>
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text strong :style="{ display: 'block' }">
{{ item.title }}
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ ACTIVITY_TYPE_LABELS[item.type] || item.type }}
·
{{ activityWhen(item) }}
</a-typography-text>
</div>
</a-flex>
</a-card>
<a-typography-link
v-if="!listExpanded && hiddenCount > 0"
@click="listExpanded = true"
>
مشاهده همه ({{ toFaDigits(summaries.length) }})
</a-typography-link>
</a-flex>
</template>
<a-flex v-else vertical :gap="token.marginSM">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-form-item label="عنوان" :style="fieldItemStyle">
<a-input v-model:value="title" placeholder="عنوان فعالیت" allow-clear />
</a-form-item>
<a-form-item label="نوع" :style="fieldItemStyle">
<a-select v-model:value="type" :options="typeOptions" style="width: 100%" />
</a-form-item>
<a-row :gutter="[12, 12]" align="top">
<a-col :span="12">
<a-form-item label="تاریخ" :style="fieldItemStyle">
<JalaliDateInput v-model="dueDate" placeholder="تاریخ" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="ساعت" :style="fieldItemStyle">
<JalaliDateInput
v-model="dueTime"
mode="time"
placeholder="اختیاری"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-typography-link v-if="summaries.length" @click="backToList">
بازگشت به لیست
</a-typography-link>
<a-typography-link @click="goFullForm">
<FormOutlined />
فرم کامل
</a-typography-link>
</a-space>
<a-space :size="8">
<a-button :disabled="saving" @click="summaries.length ? backToList() : close()">
انصراف
</a-button>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</a-space>
</a-flex>
</a-flex>
</div>
</Teleport>
</template>
@@ -21,8 +21,10 @@ withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
peekId?: string | null;
previewId?: string | null;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
);
const emit = defineEmits<{
@@ -32,6 +34,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string];
openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -109,6 +112,8 @@ function submitAdd() {
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="previewId"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@@ -117,6 +122,7 @@ function submitAdd() {
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@detail-deal="emit('detailDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@@ -26,8 +26,10 @@ const props = withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
peekId?: string | null;
previewId?: string | null;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
);
const emit = defineEmits<{
@@ -37,6 +39,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string];
openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
:copy="copy"
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
:peeking="peekId === deal.id"
:previewing="previewId === deal.id"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)"
@detail="emit('detailDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@@ -15,11 +15,13 @@ import {
Space as ASpace,
Table as ATable,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import type {
PipelineAccent,
PipelineDeal,
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال');
}
function dateHint(deal: PipelineDeal) {
if (!deal.dateIso) return deal.date;
const d = new Date(deal.dateIso);
if (Number.isNaN(d.getTime())) return deal.date;
return formatJalaliNumeric(d);
}
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
@@ -213,10 +222,12 @@ onUnmounted(() => {
</span>
</template>
<template v-else-if="column.key === 'date'">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
<a-tooltip :title="dateHint(record.deal)">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
</a-tooltip>
</template>
<template v-else-if="column.key === 'actions'">
<span :data-card-id="record.deal.id">
@@ -334,10 +345,12 @@ onUnmounted(() => {
{{ row.stageTitle }}
</a-tag>
<a-flex justify="space-between" align="center">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
<a-tooltip :title="dateHint(row.deal)">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
</a-tooltip>
<span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
@@ -24,9 +24,9 @@ import {
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX,
apiError,
localInputToIso,
@@ -68,10 +68,10 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
);
const categoryOptions = computed(() => [
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS,
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorities = computed(() =>
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
);
function startEdit() {
const t = task.value;
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }}
اولویت {{ activityPriorities.labelOf(task.priority) }}
</UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div>
@@ -23,9 +23,9 @@ import {
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
entityRoute,
@@ -64,6 +64,9 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag :bordered="false">{{ activityPriorities.labelOf(task.priority) }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</a-tag>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Button as AButton, Upload as AUpload, message } from 'ant-design-vue';
import type { UploadFile, UploadProps } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
import type { TreasuryPendingPhoto } from '@/lib/treasury-doc-photos';
export type { TreasuryPendingPhoto };
const PHOTO_LIMIT = 8;
const props = withDefaults(
defineProps<{
disabled?: boolean;
}>(),
{ disabled: false },
);
const model = defineModel<TreasuryPendingPhoto[]>({ default: () => [] });
const busy = ref(false);
const fileList = computed<UploadFile[]>(() =>
model.value.map((photo) => ({
uid: photo.localId,
name: photo.name,
status: 'done',
url: photo.dataUrl,
})),
);
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
if (props.disabled || busy.value) return AUpload.LIST_IGNORE;
if (model.value.length >= PHOTO_LIMIT) {
message.warning(`حداکثر ${PHOTO_LIMIT} عکس برای هر سند.`);
return AUpload.LIST_IGNORE;
}
const raw = file as File;
if (!raw.type.startsWith('image/')) {
message.error('فقط فایل تصویری مجاز است.');
return AUpload.LIST_IGNORE;
}
busy.value = true;
try {
const prepared = await prepareFormFieldFile(raw);
model.value = [
...model.value,
{
localId: crypto.randomUUID(),
name: prepared.name,
dataUrl: prepared.dataUrl,
file: prepared.file,
},
];
} catch (e) {
if (e instanceof FormFileTooLargeError) {
message.error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
} else {
message.error('فشرده‌سازی عکس ناموفق بود.');
}
} finally {
busy.value = false;
}
return AUpload.LIST_IGNORE;
};
const onRemove: UploadProps['onRemove'] = (file) => {
if (props.disabled || busy.value) return false;
model.value = model.value.filter((photo) => photo.localId !== file.uid);
return true;
};
</script>
<template>
<AUpload
list-type="picture"
accept="image/*"
:file-list="fileList"
:disabled="disabled || busy || model.length >= PHOTO_LIMIT"
:before-upload="beforeUpload"
:on-remove="onRemove"
multiple
>
<AButton :disabled="disabled || busy || model.length >= PHOTO_LIMIT">
<template #icon><UploadOutlined /></template>
{{ busy ? 'آماده‌سازی…' : 'افزودن عکس' }}
</AButton>
</AUpload>
</template>
@@ -0,0 +1,594 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import axios from 'axios';
import {
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Image as AImage,
Input as AInput,
Modal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Tag as ATag,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-vue';
import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
StopOutlined,
} from '@ant-design/icons-vue';
import {
canManageTreasuryDocuments,
type FinancialCategoryDto,
type FinancialDocumentDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
import { useAuthStore } from '@/stores/auth';
import { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{
rows: FinancialDocumentDto[];
categories: FinancialCategoryDto[];
statusLabel: Record<string, string>;
q: string;
}>();
const emit = defineEmits<{
'update:q': [value: string];
reload: [];
}>();
const { Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
const ADescriptionsItem = ADescriptions.Item;
const AImagePreviewGroup = AImage.PreviewGroup;
const { token } = theme.useToken();
const auth = useAuthStore();
const isMobile = useTreasuryMobile();
const canManage = computed(() =>
canManageTreasuryDocuments(auth.user?.role, auth.user?.permissions),
);
const methodLabel: Record<string, string> = {
cash: 'نقدی',
card_to_card: 'کارت‌به‌کارت',
pos: 'کارت‌خوان',
transfer: 'حواله',
other: 'سایر',
};
const methodOptions = [
{ value: 'cash', label: 'نقدی' },
{ value: 'card_to_card', label: 'کارت‌به‌کارت' },
{ value: 'pos', label: 'کارت‌خوان' },
{ value: 'transfer', label: 'حواله' },
{ value: 'other', label: 'سایر' },
];
const statusColor: Record<string, string> = {
open: 'default',
partial: 'processing',
settled: 'success',
void: 'error',
};
const detailOpen = ref(false);
const detailLoading = ref(false);
const detail = ref<FinancialDocumentDto | null>(null);
const previewUrls = ref<Record<string, string>>({});
const editOpen = ref(false);
const editSaving = ref(false);
const editId = ref<string | null>(null);
const editForm = reactive({
title: '',
notes: '',
categoryId: undefined as string | undefined,
method: undefined as string | undefined,
bookedAt: '',
amount: '',
});
const editAmountLocked = ref(true);
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
function clearPreviews() {
for (const url of Object.values(previewUrls.value)) {
URL.revokeObjectURL(url);
}
previewUrls.value = {};
}
onBeforeUnmount(clearPreviews);
watch(detailOpen, (open) => {
if (!open) {
clearPreviews();
detail.value = null;
}
});
async function loadAttachmentPreview(id: string, mimeType?: string) {
if (previewUrls.value[id]) return;
if (mimeType && !mimeType.startsWith('image/')) return;
try {
const { data } = await api.get<Blob>(`/treasury/attachments/${id}`, { responseType: 'blob' });
if (!data || data.type.includes('json') || data.size === 0) return;
if (data.type && !data.type.startsWith('image/')) return;
previewUrls.value = { ...previewUrls.value, [id]: URL.createObjectURL(data) };
} catch {
/* ignore preview failures */
}
}
async function openDetail(row: FinancialDocumentDto) {
detailOpen.value = true;
detailLoading.value = true;
detail.value = row;
clearPreviews();
try {
const { data } = await api.get<FinancialDocumentDto>(`/treasury/documents/${row.id}`);
detail.value = data;
await Promise.all(
(data.attachments ?? []).map((att) => loadAttachmentPreview(att.id, att.mimeType)),
);
} catch (e) {
message.error(apiMessage(e));
} finally {
detailLoading.value = false;
}
}
function openEdit(row: FinancialDocumentDto) {
editId.value = row.id;
editForm.title = row.title;
editForm.notes = row.notes ?? '';
editForm.categoryId = row.categoryId ?? undefined;
editForm.method = row.method ?? undefined;
editForm.bookedAt = formatLocalDateInput(new Date(row.bookedAt));
editForm.amount = String(row.amount ?? '');
editAmountLocked.value = row.status !== 'open' || Number(row.settledAmount) > 0;
editOpen.value = true;
}
async function saveEdit() {
if (!editId.value) return;
const title = editForm.title.trim();
if (title.length < 2) {
message.warning('عنوان را وارد کنید.');
return;
}
editSaving.value = true;
try {
const body: Record<string, unknown> = {
title,
notes: editForm.notes.trim() || null,
categoryId: editForm.categoryId || null,
method: editForm.method || null,
bookedAt: editForm.bookedAt || undefined,
};
if (!editAmountLocked.value) {
const amount = parseMoneyNumber(String(editForm.amount ?? ''), 0) ?? 0;
if (amount <= 0) {
message.warning('مبلغ معتبر نیست.');
editSaving.value = false;
return;
}
body.amount = amount;
}
await api.patch(`/treasury/documents/${editId.value}`, body);
message.success('سند به‌روز شد.');
editOpen.value = false;
emit('reload');
} catch (e) {
message.error(apiMessage(e));
} finally {
editSaving.value = false;
}
}
function confirmVoid(row: FinancialDocumentDto) {
Modal.confirm({
title: 'ابطال سند',
content: 'این سند و گردش متصل به آن باطل شود؟',
okText: 'ابطال',
okType: 'danger',
cancelText: 'انصراف',
async onOk() {
try {
await api.post(`/treasury/documents/${row.id}/void`, { reason: 'ابطال توسط کاربر' });
message.success('سند باطل شد.');
emit('reload');
} catch (e) {
message.error(apiMessage(e));
throw e;
}
},
});
}
function confirmDelete(row: FinancialDocumentDto) {
Modal.confirm({
title: 'حذف سند',
content:
row.status === 'void'
? 'سند باطل‌شده برای همیشه حذف شود؟'
: 'سند بدون گردش برای همیشه حذف شود؟ در صورت داشتن دریافت/پرداخت ابتدا ابطال کنید.',
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
async onOk() {
try {
await api.delete(`/treasury/documents/${row.id}`);
message.success('سند حذف شد.');
emit('reload');
} catch (e) {
message.error(apiMessage(e));
throw e;
}
},
});
}
async function downloadAttachment(id: string, fileName: string) {
try {
const { data } = await api.get<Blob>(`/treasury/attachments/${id}`, { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
} catch (e) {
message.error(apiMessage(e));
}
}
const columns = computed(() => {
const cols = [
{ title: 'پیگیری', dataIndex: 'reference', key: 'reference', width: 150 },
{ title: 'عنوان', dataIndex: 'title', key: 'title' },
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName', width: 140 },
{ title: 'دسته', dataIndex: 'categoryName', key: 'categoryName', width: 120 },
{ title: 'مبلغ', key: 'amount', width: 130 },
{ title: 'مانده', key: 'remaining', width: 130 },
{ title: 'وضعیت', key: 'status', width: 110 },
{ title: 'تاریخ', key: 'bookedAt', width: 110 },
{ title: 'عملیات', key: 'actions', width: canManage.value ? 160 : 80 },
];
return cols;
});
const searchModel = computed({
get: () => props.q,
set: (value: string) => emit('update:q', value),
});
function onSearch() {
emit('reload');
}
function asDoc(record: unknown): FinancialDocumentDto {
return record as FinancialDocumentDto;
}
</script>
<template>
<a-card :bordered="false" title="فهرست اسناد">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input
v-model:value="searchModel"
allow-clear
placeholder="عنوان یا شماره پیگیری"
@press-enter="onSearch"
@change="onSearch"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-table
v-else-if="!isMobile"
:columns="columns"
:data-source="rows"
:pagination="false"
:scroll="{ x: 1100 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'partyName'">
{{ record.partyName || 'بدون طرف حساب' }}
</template>
<template v-else-if="column.key === 'categoryName'">
{{ record.categoryName || '—' }}
</template>
<template v-else-if="column.key === 'amount'">
{{ toman(record.amount) }}
</template>
<template v-else-if="column.key === 'remaining'">
{{ toman(record.remaining) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusColor[record.status] || 'default'">
{{ statusLabel[record.status] ?? record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'bookedAt'">
{{ formatJalaliNumeric(new Date(record.bookedAt)) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space :size="4">
<a-tooltip title="جزئیات">
<a-button
type="text"
aria-label="جزئیات"
:style="{ color: token.colorInfo }"
@click="openDetail(asDoc(record))"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canManage && record.status !== 'void'" title="ویرایش">
<a-button
type="text"
aria-label="ویرایش"
:style="{ color: token.colorPrimary }"
@click="openEdit(asDoc(record))"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="record.status !== 'void'" title="ابطال">
<a-button
type="text"
aria-label="ابطال"
:style="{ color: token.colorWarning }"
@click="confirmVoid(asDoc(record))"
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canManage" title="حذف">
<a-button
type="text"
aria-label="حذف"
:style="{ color: token.colorError }"
@click="confirmDelete(asDoc(record))"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
</template>
</a-table>
<a-flex v-else vertical :gap="12">
<a-card v-for="item in rows" :key="item.id" :bordered="true">
<a-flex vertical :gap="8">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-tag :color="statusColor[item.status] || 'default'">
{{ statusLabel[item.status] ?? item.status }}
</a-tag>
</a-flex>
<a-typography-text type="secondary">
{{ item.reference ? `پیگیری ${item.reference}` : 'بدون پیگیری' }}
· {{ item.partyName || 'بدون طرف حساب' }}
</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">
{{ item.categoryName }}
</a-typography-text>
<a-flex justify="space-between" wrap="wrap" :gap="8">
<a-typography-text>مبلغ {{ toman(item.amount) }}</a-typography-text>
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ formatJalaliNumeric(new Date(item.bookedAt)) }}
</a-typography-text>
<a-space wrap :size="4">
<a-tooltip title="جزئیات">
<a-button
type="text"
aria-label="جزئیات"
:style="{ color: token.colorInfo }"
@click="openDetail(item)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canManage && item.status !== 'void'" title="ویرایش">
<a-button
type="text"
aria-label="ویرایش"
:style="{ color: token.colorPrimary }"
@click="openEdit(item)"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="item.status !== 'void'" title="ابطال">
<a-button
type="text"
aria-label="ابطال"
:style="{ color: token.colorWarning }"
@click="confirmVoid(item)"
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canManage" title="حذف">
<a-button
type="text"
aria-label="حذف"
:style="{ color: token.colorError }"
@click="confirmDelete(item)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</a-flex>
</a-card>
</a-flex>
</a-card>
<a-modal
v-model:open="detailOpen"
title="جزئیات سند"
:footer="null"
:width="isMobile ? '100%' : 720"
destroy-on-close
>
<a-flex v-if="detailLoading && !detail" justify="center">
<a-typography-text type="secondary">در حال بارگذاری…</a-typography-text>
</a-flex>
<template v-else-if="detail">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="عنوان">{{ detail.title }}</a-descriptions-item>
<a-descriptions-item label="پیگیری">{{ detail.reference || '—' }}</a-descriptions-item>
<a-descriptions-item label="طرف حساب">{{ detail.partyName || 'بدون طرف حساب' }}</a-descriptions-item>
<a-descriptions-item label="دسته">{{ detail.categoryName || '—' }}</a-descriptions-item>
<a-descriptions-item label="حساب">{{ detail.accountName || '—' }}</a-descriptions-item>
<a-descriptions-item label="روش">
{{ detail.method ? methodLabel[detail.method] || detail.method : '—' }}
</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(detail.amount) }}</a-descriptions-item>
<a-descriptions-item label="تسویه">{{ toman(detail.settledAmount) }}</a-descriptions-item>
<a-descriptions-item label="مانده">{{ toman(detail.remaining) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">
{{ statusLabel[detail.status] ?? detail.status }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ">
{{ formatJalaliNumeric(new Date(detail.bookedAt)) }}
</a-descriptions-item>
<a-descriptions-item label="یادداشت">{{ detail.notes || '—' }}</a-descriptions-item>
</a-descriptions>
<a-flex vertical :gap="12" style="margin-top: 16px">
<a-typography-text strong>پیوست‌ها</a-typography-text>
<a-empty
v-if="!(detail.attachments && detail.attachments.length)"
description="پیوستی نیست."
/>
<a-image-preview-group v-else>
<a-row :gutter="[12, 12]">
<a-col
v-for="att in detail.attachments"
:key="att.id"
:xs="24"
:sm="12"
:md="8"
>
<a-card :bordered="true">
<a-flex vertical :gap="8" align="center">
<a-image
v-if="previewUrls[att.id]"
:src="previewUrls[att.id]"
:alt="att.fileName"
style="width: 100%"
/>
<a-typography-text v-else type="secondary">{{ att.fileName }}</a-typography-text>
<a-button type="link" @click="downloadAttachment(att.id, att.fileName)">
دانلود
</a-button>
</a-flex>
</a-card>
</a-col>
</a-row>
</a-image-preview-group>
</a-flex>
</template>
</a-modal>
<a-modal
v-model:open="editOpen"
title="ویرایش سند"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="editSaving"
:width="isMobile ? '100%' : 560"
destroy-on-close
@ok="saveEdit"
>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان" required>
<a-input v-model:value="editForm.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="تاریخ">
<JalaliDateInput v-model="editForm.bookedAt" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="روش">
<a-select
v-model:value="editForm.method"
allow-clear
placeholder="روش"
style="width: 100%"
>
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="دسته‌بندی">
<a-select
v-model:value="editForm.categoryId"
allow-clear
show-search
option-filter-prop="label"
placeholder="دسته"
style="width: 100%"
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
/>
</a-form-item>
</a-col>
<a-col v-if="!editAmountLocked" :xs="24" :md="12">
<a-form-item label="مبلغ">
<MoneyInput v-model="editForm.amount" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-input v-model:value="editForm.notes" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => {
const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance';
});
watch(
active,
async () => {
await nextTick();
scroller.value
?.querySelector('.ant-segmented-item-selected')
?.scrollIntoView({ inline: 'center', block: 'nearest' });
},
{ immediate: true },
);
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template>
<div
ref="scroller"
:style="{
width: '100%',
overflowX: 'auto',

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