51 Commits
Author SHA1 Message Date
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
133 changed files with 12698 additions and 2092 deletions
-2
View File
@@ -52,7 +52,6 @@ jobs:
# dock.monsys.ir accepts the runner. # dock.monsys.ir accepts the runner.
- name: Login to registry - name: Login to registry
id: login id: login
if: false
env: env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
@@ -62,7 +61,6 @@ jobs:
- name: Push Docker images - name: Push Docker images
id: push id: push
if: false
run: | run: |
set -eu set -eu
tag="${{ steps.meta.outputs.tag }}" 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 '{}';
+42 -2
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -151,6 +162,8 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط /// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([]) enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime? priorArchiveEnabledAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -295,6 +308,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty") financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
@@ -565,6 +579,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree") children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[] people PersonGroupLink[]
companies CompanyGroupLink[] companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -883,6 +899,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime? reminderSentAt DateTime?
completedAt DateTime? completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([]) tags String[] @default([])
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -2447,8 +2469,11 @@ model FinancialPayroll {
period String period String
baseAmount Decimal @db.Decimal(18, 2) baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2) bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2) deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2) payable Decimal @db.Decimal(18, 2)
dueDate DateTime dueDate DateTime
notes String? notes String?
@@ -2953,18 +2978,28 @@ model MontifyWebhookDelivery {
@@index([connectionId, createdAt]) @@index([connectionId, createdAt])
} }
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) /// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده)
model VoipConnection { model VoipConnection {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String label String
/// generic | daftareshoma
provider String @default("generic")
enabled Boolean @default(true) enabled Boolean @default(true)
webhookSecretCiphertext String? webhookSecretCiphertext String?
webhookSecretIv String? webhookSecretIv String?
webhookSecretTag String? webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic)
clickToCallUrl String? clickToCallUrl String?
/// توکن Bearer API دفتر شما (رمزنگاری‌شده)
apiTokenCiphertext String?
apiTokenIv String?
apiTokenTag String?
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
fromNumber String?
/// خط/داخلی سازمان در دفتر شما (caller_extension)
callerExtension String?
lastEventAt DateTime? lastEventAt DateTime?
lastError String? lastError String?
calls VoipCallLog[] calls VoipCallLog[]
@@ -2972,6 +3007,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -2986,6 +3022,8 @@ model VoipCallLog {
status String @default("completed") status String @default("completed")
fromNumber String? fromNumber String?
toNumber String? toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String? personId String?
activityId String? activityId String?
durationSec Int? durationSec Int?
@@ -3010,6 +3048,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId]) @@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt]) @@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([activityId]) @@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt]) @@index([summaryStatus, summaryNextAttemptAt])
@@ -7,6 +7,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
StreamableFile, StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto, UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
} from './dto'; } from './dto';
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true }; 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') @Get('types')
listTypes(@CurrentUser() user: Actor) { listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId); return this.typesService.list(user.organizationId);
@@ -438,8 +452,16 @@ export class ActivitiesController {
} }
@Patch(':id/complete') @Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) { complete(
return this.activitiesService.complete(user.organizationId, id, user); @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') @Delete(':id')
+280 -30
View File
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' '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'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -82,6 +141,9 @@ type ActivityRow = {
reminderNotifySms: boolean; reminderNotifySms: boolean;
reminderSentAt: Date | null; reminderSentAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string; importSource?: string;
promotedFromPriorAt?: Date | null; promotedFromPriorAt?: Date | null;
tags: string[]; tags: string[];
@@ -186,22 +248,39 @@ export class ActivitiesService {
return { stages, types }; 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( async summary(
organizationId: string, organizationId: string,
actor: ActivityActor, actor: ActivityActor,
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
@@ -534,8 +613,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { data: {
@@ -550,6 +645,9 @@ export class ActivitiesService {
: stage.kind === 'open' : stage.kind === 'open'
? null ? null
: undefined, : undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
}, },
include, include,
}); });
@@ -675,6 +773,9 @@ export class ActivitiesService {
assignees: assigneeIds.length assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) } ? { connect: assigneeIds.map((id) => ({ id })) }
: undefined, : undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId, createdById: userId,
}, },
include, include,
@@ -743,8 +844,31 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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 = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) { if ('boardId' in dto) {
@@ -812,6 +936,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined; let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({ const current = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -839,6 +964,7 @@ export class ActivitiesService {
); );
data.assigneeId = assigneeIds[0]!; data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else { } else {
const assigneeId = emptyToNull(dto.assigneeId ?? null); const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +978,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId; data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }]; if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = []; else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
} }
} }
if (dto.status !== undefined) { const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
data.status = dto.status; const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
data.completedAt = if (modeChanged) data.completionMode = nextMode;
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined; 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({ const row = await this.prisma.activity.update({
@@ -881,7 +1039,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)), 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); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +1049,74 @@ export class ActivitiesService {
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, 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({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { status: 'done', completedAt: new Date() }, data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include, include,
}); });
if (existing?.status !== 'done') { if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -946,6 +1160,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, id, actor); 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({ const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId }, where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true }, select: { storageKey: true, sizeBytes: true },
@@ -966,6 +1192,19 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivityImageDto> { ): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor); 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) { if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است'); throw new BadRequestException('فایلی ارسال نشده است');
} }
@@ -1060,10 +1299,27 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, activityId, actor); 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({ const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId }, where: { id: imageId, activityId, organizationId },
}); });
if (!row) throw new NotFoundException('فایل پیدا نشد'); 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.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } }); await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes); await this.storage.addLocal(organizationId, -row.sizeBytes);
@@ -1108,16 +1364,7 @@ export class ActivitiesService {
typeRows: TypeRow[] = [], typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim(); const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim(); const facetTitle = facets?.title?.trim();
@@ -1418,6 +1665,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms, reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null, reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual', importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [], tags: row.tags ?? [],
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
ActivityParticipantsValue, ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import { import {
ACTIVITY_FIELD_TYPES, ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS, ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green'; : '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) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد'); return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+41
View File
@@ -9,11 +9,13 @@ import {
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -384,6 +386,14 @@ export class CreateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
} }
export class UpdateActivityDto { export class UpdateActivityDto {
@@ -484,4 +494,35 @@ export class UpdateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; 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, StorageUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
}, },
}); });
const role = membership?.role ?? user.role; 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 orgName = membership?.organization.name;
const sub = const sub =
subscription ?? subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage, storage,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions) permissions: permissionsWithTreasuryOverride(
? membership.orgRole.permissions.filter((item) => typeof item === 'string') rolePermissions,
: []) as AuthUserDto['permissions'], parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({ const peers = await this.prisma.teamMember.findMany({
where: { where: {
teamId: { in: memberships.map((m) => m.teamId) }, teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
}, },
select: { userId: true }, select: { userId: true },
}); });
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules, defaultEnabledModules,
isPermanentBillingPlan, isPermanentBillingPlan,
isOrgModuleKey, isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service'; import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
} }
defaultModulesForNewOrg(): OrgModuleKey[] { 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() { async countExpiringSoon() {
+138 -109
View File
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
return out; 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( export function buildContactRuleWhere(
rule: DataAccessRule, rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null }, ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null { ): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = []; const facets: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) { if (rule.owners?.length) {
if (!ids.length) return null; const ids = ownerIdList(rule, ctx);
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); if (ids.length) {
} facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
if (rule.groupIds?.length) { }
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if (rule.moods?.length) { if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } }); facets.push({ mood: { in: rule.moods } });
} }
if (!and.length) return null; return unionFacets(facets);
return and.length === 1 ? and[0]! : { AND: and };
} }
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } { 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 }; 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( export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>, actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput { ): Prisma.PersonWhereInput {
@@ -149,6 +170,8 @@ export function customersPersonWhere(
}); });
if (w) parts.push(w); if (w) parts.push(w);
} }
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = []; const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) { 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) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
} }
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if ( const w = unionFacets(facets);
rule.moods?.length && if (w) parts.push(w);
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = []; const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = []; const facets: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: rule.sources.map((s) => ({ OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const }, source: { equals: s, mode: 'insensitive' as const },
})), })),
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = []; const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = []; const facets: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
}
} }
if (rule.pipelineIds?.length) { if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } }); facets.push({ pipelineId: { in: rule.pipelineIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -266,32 +285,33 @@ export function activitiesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = []; const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = []; const facets: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } }, { participantIds: { hasSome: ids } },
], ],
}); });
}
} }
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } }); facets.push({ typeDefId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -310,34 +330,35 @@ export function notesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return personalGate; if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = []; const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = []; const facets: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ authorId: { in: ids } }, { authorId: { in: ids } },
{ {
deal: { deal: {
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
},
}, },
}, { person: { ownerId: { in: ids } } },
{ person: { ownerId: { in: ids } } }, { company: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } }, ],
], });
}); }
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } }); facets.push({ noteType: { in: rule.sources } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return { return {
AND: [personalGate, orParts(parts)], AND: [personalGate, orParts(parts)],
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = []; const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = []; const facets: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
}); });
}
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
status: { status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>, in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
}, },
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = []; const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = []; const facets: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
}
} }
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = []; const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = []; const facets: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } }); facets.push({ categoryId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
if (rule.owners?.length) { if (rule.owners?.length) {
sawOwnerRule = true; sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id); for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length || } else if (
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length || rule.sources?.length ||
rule.typeIds?.length) { rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true; sawNonOwnerRule = true;
} }
} }
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess, SectionDataAccess,
UserRole, UserRole,
} from '@mento/shared'; } 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 type { Prisma } from '@prisma/client';
import { import {
activitiesDeepWhere, activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere, notesDeepWhere,
projectsDeepWhere, projectsDeepWhere,
routinesDeepWhere, routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere, ticketsDeepWhere,
} from './data-access'; } from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>; 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 با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */ /** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T { export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope]; const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */ /** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere( export function ownerWhere(
actor: ScopedActor, actor: DeepAccessActor,
scope: DataScope = 'contacts', scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> { ): { 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); const ids = scopedOwnerIds(actor, scope);
if (!ids) return {}; if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! }; if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
} }
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ /** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere( export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
actor: ScopedActor, const deep = deepOwnerIds(actor, 'activities');
): Prisma.ActivityWhereInput { const ids =
const ids = scopedOwnerIds(actor, 'activities'); deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {}; if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return { return {
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
+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 upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay); const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return { return {
year: today.jy,
month: today.jm, month: today.jm,
monthName: jalaliMonthName(today.jm), monthName: jalaliMonthName(today.jm),
today: today.jd, today: today.jd,
+8 -3
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []), ...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
]; ];
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId, AND: [
...customersCompanyWhere(actor), {
...(and.length ? { AND: and } : {}), organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -165,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: 0, personCount: 0,
companyCount: countMap.get(g.id) ?? 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 { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module'; import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller'; import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller'; import { PeopleController } from './people.controller';
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule, PublicApiModule,
BehaviorModule, BehaviorModule,
CustomerGroupsModule, CustomerGroupsModule,
NotificationsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1) @MinLength(1)
@MaxLength(500) @MaxLength(500)
text!: string; text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
} }
export class BulkDeletePeopleDto { export class BulkDeletePeopleDto {
+2 -1
View File
@@ -139,6 +139,7 @@ export class PeopleController {
} }
@Get('birthdays') @Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) { birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user); return this.peopleService.birthdaysThisMonth(user);
} }
@@ -171,7 +172,7 @@ export class PeopleController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: BirthdaySmsDto, @Body() dto: BirthdaySmsDto,
) { ) {
return this.peopleService.sendBirthdaySms(user, id, dto.text); return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
} }
@Get(':id') @Get(':id')
+100 -11
View File
@@ -22,6 +22,7 @@ import {
FILTERED_IDS_MAX, FILTERED_IDS_MAX,
PERSON_MOODS, PERSON_MOODS,
applyPersonGenderTitle, applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific, splitPersonHonorific,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -48,6 +49,7 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service'; import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
@@ -157,6 +159,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService, private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService, private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {} ) {}
private personListWhere( private personListWhere(
@@ -216,13 +219,27 @@ export class PeopleService implements OnModuleInit {
} }
const disc = discFilter(opts?.disc); const disc = discFilter(opts?.disc);
if (disc) and.push(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 { return {
organizationId: actor.organizationId, AND: [
...customersPersonWhere(actor), {
...(moods.length ? { mood: { in: moods } } : {}), organizationId: actor.organizationId,
...(sources.length ? { importSource: { in: sources } } : {}), ...customersPersonWhere(actor),
...(and.length ? { AND: and } : {}), },
...and,
],
}; };
} }
@@ -345,6 +362,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0, personCount: countMap.get(g.id) ?? 0,
companyCount: 0, companyCount: 0,
assignees: [],
})); }));
} }
@@ -680,8 +698,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({ const rows = await this.prisma.person.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null }, birthDate: { not: null },
...customersPersonWhere(actor),
}, },
select: { select: {
id: true, id: true,
@@ -728,13 +746,17 @@ 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(); const body = text.trim();
if (!body || body.length > 500) { if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId }, where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: { select: {
id: true, id: true,
firstName: true, firstName: true,
@@ -744,11 +766,29 @@ export class PeopleService implements OnModuleInit {
ownerId: true, ownerId: true,
birthDate: true, birthDate: true,
company: { select: { name: true } }, company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
}, },
}); });
if (!person) throw new NotFoundException('مشتری پیدا نشد'); if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) { const assigneeIds = new Set(
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.'); 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) { if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.'); throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -770,6 +810,32 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone; const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const fullName = `${person.firstName} ${person.lastName}`.trim(); const fullName = `${person.firstName} ${person.lastName}`.trim();
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, { const message = renderSmsText(body, {
name: fullName, name: fullName,
phone: mobile, phone: mobile,
@@ -789,6 +855,7 @@ export class PeopleService implements OnModuleInit {
mobile, mobile,
body: message, body: message,
source: 'manual', source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed', status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason, error: result.ok ? null : result.reason,
actorUserId: actor.userId, actorUserId: actor.userId,
@@ -802,6 +869,28 @@ export class PeopleService implements OnModuleInit {
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
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 }; return { ok: true };
} }
@@ -8,7 +8,14 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS]) @IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor; color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
class UpdateCustomerGroupDto { class UpdateCustomerGroupDto {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
@Controller('customer-groups') @Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: { include: {
_count: { select: { people: true, companies: true } }, _count: { select: { people: true, companies: true } },
parent: { select: { name: true } }, parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
}, },
}); });
return rows.map((r) => ({ return rows.map((r) => this.toDto(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,
}));
} }
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: CustomerGroupColor }, dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name); const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({ const max = await this.prisma.customerGroup.aggregate({
where: { organizationId }, where: { organizationId },
_max: { position: true }, _max: { position: true },
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
name, name,
color: this.cleanColor(dto.color), color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1, position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update( async update(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null }, dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
await this.require(organizationId, id); await this.require(organizationId, id);
const parentId = const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) { if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId); await this.assertParent(organizationId, id, parentId);
} }
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try { try {
await this.prisma.customerGroup.update({ await this.prisma.customerGroup.update({
where: { id }, where: { id },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}), ...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null; 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) { private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({ const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import { import {
assertFactorCode, assertFactorCode,
buildFactorCode, buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index'); 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() { function main() {
testCryptoRoundtrip(); testCryptoRoundtrip();
testFactorCode(); testFactorCode();
testMobile(); testMobile();
testMandehNormalize(); testMandehNormalize();
testSanadBuilders(); testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK'); console.log('tahesab unit checks: OK');
} }
+6
View File
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number; pageSize?: number;
} }
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto { export class ExportPeopleToTahesabDto {
@IsString() @IsString()
connectionId!: string; 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, SearchTahesabDto,
SyncGroupsPageDto, SyncGroupsPageDto,
} from './dto'; } from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel'; import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */ /** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); 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 { function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined; if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim()); 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) { async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi( const { api } = await this.connections.getApi(
organizationId, organizationId,
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
}); });
const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); 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; const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return { return {
...row, ...row,
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
: null, : 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing), ...canonicalMobileUpdate(mobile, existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود. // تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile); const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length const matches = mobileKeys.length
? await this.prisma.person.findMany({ ? await this.prisma.person.findMany({
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode; 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 = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef, sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile), ...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
} else { } else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({ const created = await this.prisma.person.create({
data: { data: {
organizationId, organizationId,
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, 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; 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({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) }; return { personId, link: this.linkDto(link) };
} }
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ /** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer( private async refreshLinkedCustomer(
organizationId: string, organizationId: string,
connectionId: string, connectionId: string,
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: String(moshtariCode), sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
await this.applyTahesabGroup(organizationId, connectionId, personId, row); await this.applyTahesabGroup(organizationId, connectionId, personId, row);
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
let emptyStreak = 0; let emptyStreak = 0;
let imported = 0; let imported = 0;
let updated = 0; let updated = 0;
let duplicate = 0;
let failed = 0; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
const maxPages = 300; const maxPages = 300;
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
); );
updated += 1; updated += 1;
} else { } else {
await this.importFromTahesab( const created = await this.importFromTahesab(
organizationId, organizationId,
{ connectionId, moshtariCode: code }, { connectionId, moshtariCode: code },
{ api, row }, { api, row },
{ skipJob: true }, { skipJob: true },
); );
imported += 1; if (created.duplicateOf) duplicate += 1;
else imported += 1;
} }
} catch (err) { } catch (err) {
failed += 1; failed += 1;
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); 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({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId, connectionId,
kind: 'customer-sync', kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', 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}`); this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { return {
imported, imported,
updated, updated,
duplicate,
failed, failed,
summary, summary,
error: errors.length ? errors.join('؛ ') : null, error,
}; };
} }
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); const row = rowsByCode.get(moshtariCode);
if (birthDate) { if (row) {
await this.prisma.person.updateMany({ await this.refreshLinkedCustomer(
where: { id: linkedPersonId, birthDate: null }, organizationId,
data: { birthDate }, dto.connectionId,
}); linkedPersonId,
moshtariCode,
row,
);
} }
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode }, { connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined, 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 }); items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) { } catch (e) {
const message = const message =
@@ -37,6 +37,7 @@ import {
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto, SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto, UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto); 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') @Get('sync/unlinked-people')
@Roles('owner', 'admin') @Roles('owner', 'admin')
unlinkedPeople( unlinkedPeople(
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { AiService } from '../../ai/ai.service'; import { AiService } from '../../ai/ai.service';
import { NotesService } from '../../notes/notes.service'; import { NotesService } from '../../notes/notes.service';
import { PrismaService } from '../../prisma/prisma.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'; import { SttClientService } from './stt-client.service';
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
@@ -17,6 +20,8 @@ export class CallSummaryService {
private readonly stt: SttClientService, private readonly stt: SttClientService,
private readonly ai: AiService, private readonly ai: AiService,
private readonly notes: NotesService, private readonly notes: NotesService,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
@Cron('*/1 * * * *') @Cron('*/1 * * * *')
@@ -74,7 +79,31 @@ export class CallSummaryService {
); );
return; 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({ await this.prisma.voipCallLog.update({
where: { id }, where: { id },
data: { transcript }, data: { transcript },
+159 -4
View File
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, ''); return (value ?? '').replace(/\D/g, '');
@@ -57,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService, private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, 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' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -91,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -127,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || 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 matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -193,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -213,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -240,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber); 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({ const link = await this.prisma.voipCallLog.findUnique({
where: { where: {
connectionId_externalCallId: { connectionId_externalCallId: {
@@ -261,10 +296,67 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: 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); const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست'); 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({ const conn = await this.prisma.voipConnection.findFirst({
where: { where: {
organizationId, organizationId,
@@ -318,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; 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 { private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status); return ['answered', 'completed', 'missed', 'failed'].includes(status);
} }
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest, CreateVoipConnectionRequest,
UpdateVoipConnectionRequest, UpdateVoipConnectionRequest,
VoipConnectionDto, VoipConnectionDto,
VoipProvider,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable() @Injectable()
export class VoipConnectionsService { export class VoipConnectionsService {
constructor( constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> { ): Promise<VoipConnectionDto> {
const label = dto.label?.trim(); const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است'); 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 secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null; 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({ const row = await this.prisma.voipConnection.create({
data: { data: {
organizationId, organizationId,
label, label,
provider,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
clickToCallUrl, clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv, webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag, webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
}, },
}); });
return this.toDto(row, 0); return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) { if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); 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) { if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim(); const wh = dto.webhookSecret?.trim();
if (!wh) { if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag; 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({ const row = await this.prisma.voipConnection.update({
where: { id: existing.id }, where: { id: existing.id },
data, 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( verifySignature(
rawBody: string | Buffer, rawBody: string | Buffer,
signature: string | undefined, signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: { row: {
id: string; id: string;
label: string; label: string;
provider?: string | null;
enabled: boolean; enabled: boolean;
webhookSecretCiphertext: string | null; webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null; clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null; lastEventAt: Date | null;
lastError: string | null; lastError: string | null;
createdAt: Date; createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return { return {
id: row.id, id: row.id,
label: row.label, label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled, enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext), webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl, clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError, lastError: row.lastError,
callCount, 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 { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80) @MaxLength(80)
label!: string; label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; 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() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); 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(); const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) { if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
} }
const timeoutMs = Number( const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000, this.config.get<string>('STT_TIMEOUT_MS') || 180000,
); );
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || ''; const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData(); const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], { const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType, type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token; if (token) headers['X-STT-Token'] = token;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), waitMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto, UpdateVoipConnectionDto,
VoipClickToCallDto, VoipClickToCallDto,
} from './dto'; } from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip') @Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: 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') @Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); 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' }; 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 }; return { ok: true, ...result };
} }
} }
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service'; import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service'; import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller'; import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -258,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -291,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
+89 -16
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,22 +171,77 @@ export class LeadsService {
if (opts.hasMobile) { if (opts.hasMobile) {
and.push({ mobile: { not: '' } }); 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 { return {
organizationId: actor.organizationId, AND: [
...leadAccessWhere(actor), {
archivedAt: opts.archived ? { not: null } : null, organizationId: actor.organizationId,
...(opts.stageId ? { stageId: opts.stageId } : {}), ...leadAccessWhere(actor),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}), archivedAt: opts.archived ? { not: null } : null,
...(opts.unassigned ? { assigneeId: null } : {}), ...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.region ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
? { region: { contains: opts.region, mode: 'insensitive' } } ...(opts.region
: {}), ? { region: { contains: opts.region, mode: 'insensitive' } }
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}), : {}),
...(opts.source ...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
? { source: { contains: opts.source, mode: 'insensitive' } } ...(opts.source
: {}), ? { source: { contains: opts.source, mode: 'insensitive' } }
...(and.length ? { AND: and } : {}), : {}),
},
...(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; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
sort?: string; sort?: string;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Promise<PaginatedResponse<LeadDto>> { ): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId); await this.stages.ensureDefaults(actor.organizationId);
@@ -243,7 +312,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
@@ -1116,7 +1189,7 @@ export class LeadsService {
newStage newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0), : Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }), this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length convertedIds.length
? this.prisma.lead.count({ ? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } }, where: { ...base, stageId: { in: convertedIds } },
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, 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: { async createMembership(data: {
userId: string; userId: string;
organizationId: string; organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number; commissionPercent?: number;
isActive?: boolean; isActive?: boolean;
}) { }) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({ return this.prisma.membership.create({
data: { data: {
userId: data.userId, userId: data.userId,
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {} constructor(private readonly pipelinesService: PipelinesService) {}
@Get() @Get()
list(@CurrentUser() user: { organizationId: string }) { list(
return this.pipelinesService.list(user.organizationId); @CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
} }
@Get('board') @Get('board')
+154 -9
View File
@@ -4,6 +4,10 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto, DealDto,
MergePipelineResultDto, MergePipelineResultDto,
PipelineBoardDto, PipelineBoardDto,
@@ -102,24 +106,55 @@ export class PipelinesService {
return this.toPipelineDto(created); 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); await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({ const rows = await this.prisma.pipeline.findMany({
where: { organizationId }, where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } }, include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
}); });
const counts = await this.prisma.deal.groupBy({ const lens = actor?.priorLens;
by: ['pipelineId'], const lensActive = forLens && (lens === 'current' || lens === 'archive');
where: { organizationId }, const access = actor ? dealAccessWhere(actor) : {};
_count: { _all: true }, 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( const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]), 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), ...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 rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>(); const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []); for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) { for (const deal of deals) {
const list = byStage.get(deal.stageId); 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 { 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 { toDealDto(row: DealRow): DealDto {
return { return {
id: row.id, id: row.id,
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } 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 { export class PlatformLoginDto {
@IsString() @IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null; quotaGb!: number | null;
} }
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto { export class UpdateOrgModulesDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { import {
ApplyBillingPlanDto, ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto, SetStorageQuotaDto,
SetUserCapDto, SetUserCapDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers); 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') @Patch('organizations/:id/modules')
updateOrganizationModules( updateOrganizationModules(
@Param('id') id: string, @Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformStatsDto, PlatformStatsDto,
PlatformUserDto, PlatformUserDto,
ProductPlanCode,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { isBusinessField } from '@mento/shared'; import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId); 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) { async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({ const existing = await this.prisma.organization.findUnique({
where: { id }, where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) { if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId); await this.billing.assertSeatAvailable(row.organizationId, row.userId);
} }
if (!isActive && row.role === 'owner') { if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); 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') { if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
row.organizationId, row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
assigneeWhere, assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance'); const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor); const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime(); const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1); const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId); const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date(); const now = new Date();
const { jy, jm } = dateToJalali(now); const { jy, jm } = dateToJalali(now);
+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 { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { import {
parseUserTreasuryAccess, parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed, treasurySectionAllowed,
type TreasurySectionKey, type TreasurySectionKey,
} from '@mento/shared'; } from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { 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; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true; 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) const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String) ? membership.orgRole.permissions.map(String)
: []; : [];
const permissions = permissionsForTreasury( const permissions = permissionsWithTreasuryOverride(
rolePermissions, rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride), parseUserTreasuryAccess(membership?.treasuryAccessOverride),
); );
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || ''; 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))) { if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.'); throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
} }
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
} }
} }
function permissionsForTreasury( /** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
rolePermissions: string[], function isLookupForDocuments(method: string, url: string): boolean {
override: ReturnType<typeof parseUserTreasuryAccess>, if (method !== 'GET' && method !== 'HEAD') return false;
): string[] { const path = (url.split('?')[0] || '').replace(/\/+$/, '');
if (!override) return rolePermissions; return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
} }
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' { 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 type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); 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) { function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2); 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) { function parseDate(iso?: string | null) {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d; 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) { function periodKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -36,9 +46,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -55,8 +71,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: string | number; baseAmount: string | number;
benefits?: string | number; benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number; bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number; deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string; dueDate?: string;
notes?: string | null; notes?: string | null;
}, },
@@ -81,14 +100,23 @@ export class TreasuryOpsService {
period: input.period.trim(), period: input.period.trim(),
baseAmount: base, baseAmount: base,
benefits, benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus, bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions, deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable, payable,
dueDate: parseDate(input.dueDate) ?? new Date(), dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, 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 } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -124,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id); const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); 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([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -145,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor); 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) { async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({ const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
@@ -327,6 +472,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', 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.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +558,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -475,12 +627,22 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: Prisma.Decimal; baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal; benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal; bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal; deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; 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, fullName: string,
) { ) {
@@ -493,13 +655,25 @@ export class TreasuryOpsService {
period: row.period, period: row.period,
baseAmount: money(row.baseAmount), baseAmount: money(row.baseAmount),
benefits: money(row.benefits), benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus), bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions), deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable), payable: money(row.payable),
paidAmount: money(paid), paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, 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'); 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') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -232,6 +255,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body); 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') @Post('payroll/:id/pay')
payPayroll( payPayroll(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+324 -9
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -359,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
...(from || to ...(from || to
? { ? {
occurredAt: { occurredAt: {
@@ -377,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from }, occurredAt: { lt: from },
}, },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
@@ -530,6 +533,8 @@ export class TreasuryService {
settleAmount?: string | number | null; settleAmount?: string | number | null;
accountId?: string | null; accountId?: string | null;
settledAt?: string | null; settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
}, },
): Promise<FinancialDocumentDto> { ): Promise<FinancialDocumentDto> {
const title = input.title.trim(); const title = input.title.trim();
@@ -560,6 +565,17 @@ export class TreasuryService {
} }
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); 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 doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference( const reference = await this.nextDocumentReference(
tx, tx,
@@ -606,6 +622,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({ return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -727,6 +782,7 @@ export class TreasuryService {
amount: Prisma.Decimal; amount: Prisma.Decimal;
settled: Prisma.Decimal; settled: Prisma.Decimal;
remaining: Prisma.Decimal; remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>(); }>();
for (const row of rows) { for (const row of rows) {
if (row.partyKind === 'other') continue; if (row.partyKind === 'other') continue;
@@ -747,13 +803,43 @@ export class TreasuryService {
amount: new Prisma.Decimal(0), amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0), settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0), remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
}; };
current.amount = current.amount.plus(row.amount); current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled); current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining); current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current); 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()] return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining)) .sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({ .map((row) => ({
key: row.key, key: row.key,
@@ -762,9 +848,122 @@ export class TreasuryService {
amount: money(row.amount), amount: money(row.amount),
settledAmount: money(row.settled), settledAmount: money(row.settled),
remaining: money(row.remaining), 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('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
}
if (input.kind === 'expense') {
const balance = await this.accountBalance(account.id);
if (balance.lessThan(amount)) {
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( private async resolveParty(
actor: Actor, actor: Actor,
input: { input: {
@@ -908,16 +1107,51 @@ export class TreasuryService {
}); });
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date(); const now = new Date();
await this.prisma.$transaction([ await this.prisma.$transaction(async (tx) => {
this.prisma.financialMovement.updateMany({ 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 }, where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now }, 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 }, where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, 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({ const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id }, where: { id: doc.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -1092,6 +1326,17 @@ export class TreasuryService {
} }
const bookedIncome = dec(income._sum.amount); const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._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( const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance), (sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -1101,7 +1346,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)), actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)), actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables), receivables: money(receivables),
payables: money(payables), payables: money(payables),
totalBalance: money(totalBalance), totalBalance: money(totalBalance),
@@ -1111,6 +1356,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 }>) { private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements return movements
.filter((row) => !row.voidedAt) .filter((row) => !row.voidedAt)
@@ -1119,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) { private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null }, where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
}); });
return rows.reduce( return rows.reduce(
@@ -1194,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({ 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 }, select: { direction: true, amount: true, kind: true, occurredAt: true },
}); });
let balance = new Prisma.Decimal(0); let balance = new Prisma.Decimal(0);
+15 -3
View File
@@ -104,8 +104,10 @@ export class UsersService {
orgRoleId: dto.orgRoleId, orgRoleId: dto.orgRoleId,
role: dto.role, role: dto.role,
}); });
if (resolved.role === 'owner' && actorRole !== 'owner') { if (resolved.role === 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
} }
const existingUser = await this.prisma.user.findFirst({ where: { mobile } }); 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') { if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
organizationId, organizationId,
@@ -456,7 +462,7 @@ export class UsersService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
@@ -528,6 +534,9 @@ export class UsersService {
} }
} }
if (isActive && !target.isActive) { if (isActive && !target.isActive) {
if (target.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, targetId);
}
await this.billing.assertSeatAvailable(organizationId, targetId); await this.billing.assertSeatAvailable(organizationId, targetId);
} }
await this.prisma.membership.update({ await this.prisma.membership.update({
@@ -558,6 +567,9 @@ export class UsersService {
resolved: { role: UserRole; orgRoleId: string }, resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> { ): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId); await this.billing.assertSeatAvailable(organizationId, userId);
if (resolved.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, userId);
}
const existing = await this.prisma.membership.findFirst({ const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
}); });
+52 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; 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 { import {
noteAccessWhere, noteAccessWhere,
normalizeDataVisibility, normalizeDataVisibility,
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخش‌ها را ذ
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] }); 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> <strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button> <button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header> </header>
<p v-if="isPeople" class="facet-hint">
گروه، منبع و وضعیت با هم جمع می‌شوند؛ هر کدام کافی است (بدون تکرار).
</p>
<div class="panel-body"> <div class="panel-body">
<section v-if="hasSort" class="sec"> <section v-if="hasSort" class="sec">
@@ -264,6 +267,15 @@ function apply() {
border-bottom: 1px solid var(--line); 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 { .panel-foot {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
justify-content: flex-end; justify-content: flex-end;
+237 -32
View File
@@ -1,29 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import axios from 'axios'; 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 { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import { import {
CUSTOMER_GROUP_COLOR_LABELS, CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch, groupSwatch,
useCustomerGroups, useCustomerGroups,
} from '@/lib/customer-groups'; } from '@/lib/customer-groups';
import { useAuthStore } from '@/stores/auth';
import { UiButton, UiInput, UiModal } from '@/components/ui'; import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
const props = defineProps<{ open: boolean }>(); const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>(); const emit = defineEmits<{ close: []; changed: [] }>();
const auth = useAuthStore();
const canManage = computed(
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
);
const { groups, load } = useCustomerGroups(); const { groups, load } = useCustomerGroups();
const users = ref<OrgUserDto[]>([]);
const newName = ref(''); const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal'); const newColor = ref<CustomerGroupColor>('teal');
const newAssigneeIds = ref<string[]>([]);
const editingId = ref<string | null>(null); const editingId = ref<string | null>(null);
const editName = ref(''); const editName = ref('');
const assigneeDraft = ref<Record<string, string[]>>({});
const listQuery = ref('');
const busy = ref(false); const busy = ref(false);
const error = ref(''); 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) { function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message; const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
return fallback; 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) { async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true; busy.value = true;
error.value = ''; error.value = '';
try { try {
await task(); await task();
await load(true); await load(true);
syncAssigneeDraft();
emit('changed'); emit('changed');
return true; return true;
} catch (e) { } catch (e) {
@@ -53,10 +96,18 @@ async function create() {
const name = newName.value.trim(); const name = newName.value.trim();
if (!name) return; if (!name) return;
const ok = await run( 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) { function startEdit(g: CustomerGroupDto) {
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.'); 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) { async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount; const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : ''; const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
@@ -95,9 +164,12 @@ watch(
if (!open) return; if (!open) return;
error.value = ''; error.value = '';
editingId.value = null; editingId.value = null;
void load(true).catch(() => { listQuery.value = '';
error.value = 'بارگذاری گروه‌ها ناموفق بود.'; void Promise.all([load(true), loadUsers()])
}); .then(() => syncAssigneeDraft())
.catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
}, },
{ immediate: true }, { immediate: true },
); );
@@ -107,34 +179,60 @@ watch(
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')"> <UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager"> <div class="manager">
<p class="lead"> <p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب هر شخص یا شرکت می‌تواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
«ته‌حساب» مشخص شده‌اند. مشتریان خود می‌بینند. گروه‌های ته‌حساب با برچسب «ته‌حساب» مشخص شده‌اند.
</p> </p>
<form class="new-row" @submit.prevent="create"> <form v-if="canManage" class="create-panel" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" /> <header class="create-head">
<div class="swatches"> <strong>گروه جدید</strong>
<button <span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
v-for="c in CUSTOMER_GROUP_COLORS" </header>
:key="c" <div class="new-row">
type="button" <UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
class="swatch" <div class="swatches">
:class="{ on: newColor === c }" <button
:style="{ background: groupSwatch(c).fg }" v-for="c in CUSTOMER_GROUP_COLORS"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]" :key="c"
@click="newColor = 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> </div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form> </form>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list"> <div v-if="canManage || groups.length" class="list-head">
<li v-for="g in groups" :key="g.id" class="row"> <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"> <div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)"> <form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" /> <UiInput v-model="editName" :maxlength="80" :disabled="busy" />
@@ -152,9 +250,10 @@ watch(
<span class="count"> <span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت {{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span> </span>
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
</template> </template>
</div> </div>
<div v-if="editingId !== g.id" class="row-actions"> <div v-if="editingId !== g.id && canManage" class="row-actions">
<div class="swatches"> <div class="swatches">
<button <button
v-for="c in CUSTOMER_GROUP_COLORS" v-for="c in CUSTOMER_GROUP_COLORS"
@@ -173,8 +272,20 @@ watch(
<UiIcon name="trash" :size="14" /> <UiIcon name="trash" :size="14" />
</UiButton> </UiButton>
</div> </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> </li>
</ul> </ul>
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p> <p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div> </div>
</UiModal> </UiModal>
@@ -200,6 +311,78 @@ watch(
font-size: 0.85rem; 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, .new-row,
.edit { .edit {
display: flex; display: flex;
@@ -240,11 +423,9 @@ watch(
.list { .list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0 0 0.25rem;
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
} }
.row { .row {
@@ -282,14 +463,38 @@ watch(
padding: 0.05rem 0.4rem; padding: 0.05rem 0.4rem;
} }
.count { .count,
.assignee-summary {
color: #94a3b8; color: #94a3b8;
font-size: 0.78rem; font-size: 0.78rem;
} }
.assignee-summary {
flex: 1 1 100%;
}
.row-actions { .row-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; 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> </style>
+33 -24
View File
@@ -22,7 +22,6 @@ const props = withDefaults(
defineProps<{ defineProps<{
modelValue: SectionDataAccess; modelValue: SectionDataAccess;
section?: DataAccessSection; section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[]; stages?: IdOption[];
pipelines?: IdOption[]; pipelines?: IdOption[];
boards?: IdOption[]; boards?: IdOption[];
@@ -36,7 +35,6 @@ const props = withDefaults(
}>(), }>(),
{ {
section: 'customers', section: 'customers',
groups: () => [],
stages: () => [], stages: () => [],
pipelines: () => [], pipelines: () => [],
boards: () => [], boards: () => [],
@@ -53,9 +51,9 @@ const emit = defineEmits<{
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]); const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers'); const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers'); const showImportSources = computed(() => props.section === 'customers');
const isCustomers = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads'); const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes'); const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets'); const showTicketStatuses = computed(() => props.section === 'tickets');
@@ -119,9 +117,8 @@ const boardsLegend = computed(() =>
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */ /** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule { function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {}; 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 owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>(); const sources = new Set<string>();
const moods = new Set<PersonMood>(); const moods = new Set<PersonMood>();
const stageIds = new Set<string>(); const stageIds = new Set<string>();
@@ -130,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
const typeIds = new Set<string>(); const typeIds = new Set<string>();
for (const r of rules) { for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o); 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 s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m); for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id); for (const id of r.stageIds ?? []) stageIds.add(id);
@@ -140,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
} }
const out: DataAccessRule = {}; const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners]; if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources]; if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods]; if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds]; if (stageIds.size) out.stageIds = [...stageIds];
@@ -150,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
return out; 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 { function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean( return Boolean(
rule.owners?.length || rule.owners?.length ||
rule.groupIds?.length || (!isCustomers.value && rule.groupIds?.length) ||
rule.sources?.length || rule.sources?.length ||
rule.moods?.length || rule.moods?.length ||
rule.stageIds?.length || rule.stageIds?.length ||
@@ -184,10 +186,26 @@ const mode = computed({
const filter = computed(() => collapseRules(props.modelValue?.rules ?? [])); 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) { function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {}; const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners]; 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.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods]; if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds]; if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
@@ -210,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
} }
type ListKey = type ListKey =
| 'groupIds'
| 'sources' | 'sources'
| 'moods' | 'moods'
| 'stageIds' | 'stageIds'
@@ -293,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
const pickers = computed((): Picker[] => { const pickers = computed((): Picker[] => {
const rule = filter.value; const rule = filter.value;
const items: Picker[] = []; 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) { if (showImportSources.value) {
const ready = props.importSourcesReady; const ready = props.importSourcesReady;
const failed = props.importSourcesFailed; const failed = props.importSourcesFailed;
@@ -417,7 +424,6 @@ const filterSummary = computed(() => {
const parts: string[] = []; const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value); if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو'); 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.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`); if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`); if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
@@ -443,9 +449,12 @@ const filterSummary = computed(() => {
<template v-if="mode === 'rules'"> <template v-if="mode === 'rules'">
<p class="hint"> <p class="hint">
فقط رکوردهایی دیده می‌شوند که با فیلترهای پرشده با هم
<strong>همهٔ فیلترهای پرشده</strong> <strong>جمع می‌شوند</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت. ؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
<template v-if="isCustomers">
اعضای گروه‌هایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه می‌شوند.
</template>
</p> </p>
<p v-if="hadMultipleRules" class="hint warn"> <p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود. تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
@@ -16,7 +16,6 @@ import {
emptySectionDataAccess, emptySectionDataAccess,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue'; import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{ 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 importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false); const importSourcesReady = ref(false);
const importSourcesFailed = ref(false); const importSourcesFailed = ref(false);
@@ -142,7 +140,6 @@ async function loadTreasuryAccounts() {
} }
async function loadOptions() { async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try { try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats'); const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label })); importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
@@ -362,7 +359,6 @@ const typesForActive = computed(() => {
:key="active" :key="active"
:section="editorSection" :section="editorSection"
:model-value="sectionValue(editorSection)" :model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive" :stages="stagesForActive"
:pipelines="pipelines" :pipelines="pipelines"
:boards="boardsForActive" :boards="boardsForActive"
+9 -2
View File
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string; placeholder?: string;
clearable?: boolean; clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell'; icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(), }>(),
{ {
mode: 'date', mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false, disabled: false,
clearable: true, clearable: true,
icon: undefined, icon: undefined,
displayText: '',
startView: 'days',
}, },
); );
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
}); });
const display = computed(() => { const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') { if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value); const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return ''; if (!match) return '';
@@ -353,7 +360,7 @@ function place() {
function liftAboveOverlays() { function liftAboveOverlays() {
let z = 120; 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); const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20); if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
} }
@@ -368,7 +375,7 @@ function onWindowChange() {
async function show() { async function show() {
if (props.disabled) return; if (props.disabled) return;
syncDraftFrom(parsed.value); syncDraftFrom(parsed.value);
calView.value = 'days'; calView.value = props.startView;
clockFace.value = 'hour'; clockFace.value = 'hour';
syncViewport(); syncViewport();
liftAboveOverlays(); liftAboveOverlays();
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */ /** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean; asNumber?: boolean;
dir?: 'ltr' | 'rtl'; dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(), }>(),
{ {
required: false, required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2, decimals: 2,
asNumber: false, asNumber: false,
dir: 'ltr', dir: 'ltr',
addonAfter: '',
}, },
); );
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:value="text" :value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums" style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @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>
+13 -3
View File
@@ -122,17 +122,27 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; return fallback;
} }
async function send() { async function send(force = false) {
if (!props.endpoint || busy.value || done.value) return; if (!props.endpoint || busy.value || done.value) return;
busy.value = true; busy.value = true;
error.value = ''; error.value = '';
try { try {
await api.post(props.endpoint, { text: text.value }); await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
done.value = 'پیامک ارسال شد.'; done.value = 'پیامک ارسال شد.';
emit('sent'); emit('sent');
void loadHistory(); void loadHistory();
} catch (e) { } 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 { } finally {
busy.value = false; busy.value = false;
} }
@@ -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; tableKey: string;
label?: string; label?: string;
align?: 'start' | 'end'; align?: 'start' | 'end';
/** فقط آیکون تنظیمات (بدون متن «ستون‌ها») */
iconOnly?: boolean;
}>(), }>(),
{ label: 'ستون‌ها', align: 'start' }, { label: 'ستون‌ها', align: 'start', iconOnly: false },
); );
const open = ref(false); const open = ref(false);
@@ -60,11 +62,18 @@ const placement = computed(() =>
</a-button> </a-button>
</a-flex> </a-flex>
</template> </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> <template #icon><SettingOutlined /></template>
{{ label }} <template v-if="!iconOnly">
<template v-if="hiddenCount"> {{ label }}
· {{ toFaDigits(hiddenCount) }} مخفی <template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</template> </template>
</a-button> </a-button>
</a-popover> </a-popover>
+90 -4
View File
@@ -22,6 +22,7 @@ import {
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui'; import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali'; import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { matchesSearch } from '@/lib/search';
const props = defineProps<{ const props = defineProps<{
users: OrgUserDto[]; users: OrgUserDto[];
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'), props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
); );
const 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) { async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId; priorSavingId.value = userId;
priorPick.value = ''; priorPick.value = '';
@@ -383,14 +407,22 @@ onMounted(() => {
</section> </section>
<section class="card"> <section class="card">
<h3>تیم‌ها</h3> <header class="card-head">
<h3>تیم‌ها</h3>
<UiInput
v-if="teams.length"
v-model="teamsQuery"
placeholder="جستجوی تیم یا عضو…"
/>
</header>
<UiEmptyState <UiEmptyState
v-if="!loading && !teams.length" v-if="!loading && !teams.length"
title="تیمی تعریف نشده" title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند." description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/> />
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
<div v-else class="team-list"> <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> <header>
<div> <div>
<strong>{{ t.name }}</strong> <strong>{{ t.name }}</strong>
@@ -504,7 +536,14 @@ onMounted(() => {
</section> </section>
<section class="card"> <section class="card">
<h3>محدوده داده</h3> <header class="card-head">
<h3>محدوده داده</h3>
<UiInput
v-if="editableUsers.length"
v-model="accessQuery"
placeholder="جستجوی کاربر…"
/>
</header>
<p class="hint"> <p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید. جدا بگذارید.
@@ -514,8 +553,9 @@ onMounted(() => {
title="کاربر قابل تنظیم نیست" title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند." description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/> />
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
<div v-else class="vis-table"> <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"> <div class="vis-row">
<span> <span>
{{ u.fullName }} {{ u.fullName }}
@@ -567,6 +607,22 @@ onMounted(() => {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 1rem; 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 { .prior-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -722,5 +778,35 @@ select {
.prior-picker { .prior-picker {
grid-template-columns: 1fr; 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> </style>
+214 -21
View File
@@ -1,20 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { Select as ASelect } from 'ant-design-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( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string[]; modelValue: string[];
users: PickUser[]; users: PickUser[];
/** انتخاب یک نقش، همهٔ اعضایش را مسئول می‌کند */
roles?: AssigneeGroup[];
/** انتخاب یک تیم، همهٔ اعضایش را مسئول می‌کند */
teams?: AssigneeGroup[];
disabled?: boolean; disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */ /** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number; min?: number;
addLabel?: string; addLabel?: string;
emptyLabel?: string; emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(), }>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' }, {
roles: () => [],
teams: () => [],
disabled: false,
min: 0,
addLabel: 'افزودن',
emptyLabel: 'کسی انتخاب نشده',
selectAll: false,
},
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,32 +39,208 @@ const emit = defineEmits<{
change: [ids: string[]]; change: [ids: string[]];
}>(); }>();
const options = computed(() => const pinned = ref(new Set<string>());
props.users.map((u) => ({ value: u.id, label: u.fullName })),
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 selectValue = computed(() => {
const ids = Array.isArray(value) ? value.map(String) : []; 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) { if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]); emit('update:modelValue', [...props.modelValue]);
return; return;
} }
emit('update:modelValue', ids); 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> </script>
<template> <template>
<a-select <div class="user-multi" :class="{ 'with-all': selectAll }">
mode="multiple" <a-select
allow-clear mode="multiple"
show-search allow-clear
option-filter-prop="label" show-search
:value="modelValue" :value="selectValue"
:options="options" :options="options"
:disabled="disabled" :disabled="disabled"
:placeholder="emptyLabel" :placeholder="emptyLabel"
style="width: 100%" :filter-option="filterOption"
@update:value="onUpdate" :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> </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'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types'; import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import { import {
ACTIVITY_BOARD_COPY, ACTIVITY_BOARD_COPY,
initialsFromTitle, initialsFromTitle,
@@ -41,6 +42,7 @@ const emit = defineEmits<{
stagesChanged: []; stagesChanged: [];
add: [stageId: string]; add: [stageId: string];
error: [message: string]; error: [message: string];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string]; menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string]; toggleSelect: [activityId: string];
bulkDelete: []; bulkDelete: [];
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null); const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green'); const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 }); 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 moving = ref(false);
const stageBusy = ref(false); const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null); 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> = { const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined, open: undefined,
@@ -88,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; 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 { function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || ''; const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length const assigneeNames = item.assignees?.length
@@ -112,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
title: item.title, title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '), description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null, amount: null,
date: dateText(item), date:
item.status === 'done'
? 'انجام شده'
: item.dueAt
? formatRelativeDayLabel(item.dueAt)
: 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title), initials: initialsFromTitle(party || item.title),
stageId, stageId,
createdAt: item.createdAt, createdAt: item.createdAt,
@@ -121,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
ownerId: item.assignee?.id ?? null, ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id), assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority, priority: item.priority,
activityStatus: item.status,
}; };
} }
@@ -222,12 +259,12 @@ function onMenuActivity(id: string) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id; draggingColumnId.value = null;
overlayDeal.value = deal; // قفل/صف مانده از درخواست قبلی را باز کن
const sid = findStageOf(deal.id); if (moving.value) resetMoveLock();
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green'; dragSessionId = deal.id;
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 }; // setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
@@ -235,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); 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() { 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; draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
} }
function onDragOver(stageId: string, event: DragEvent) { 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) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId];
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId); const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex]; const item = from.items[fromIndex];
if (!item) return; 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 same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId); const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return; 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; if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length)); insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return; if (same && insertAt === fromIndex) return;
@@ -287,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
moving.value = true; moving.value = true;
armMoveWatchdog();
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, stageId: toStageId,
position: insertAt, 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; const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i)); column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed'); emit('changed');
@@ -304,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
window.clearTimeout(moveWatchdog);
moving.value = false; moving.value = false;
} }
} }
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
dropStageId.value = null; const cardId = dragSessionId || draggingId.value;
draggingId.value = null; const columnId = draggingColumnId.value;
overlayDeal.value = null; clearDragState();
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value; // کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
draggingColumnId.value = null; const activityId =
if (fromId) await reorderColumn(fromId, stageId); cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return; 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[]) { 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> = { const query: Record<string, string> = {
from: 'desk', from: 'desk',
nav: crypto.randomUUID(), nav: crypto.randomUUID(),
@@ -424,7 +517,31 @@ function openActivity(id: string) {
if (source.due) query.due = source.due; if (source.due) query.due = source.due;
const columnId = findStageOf(id); const columnId = findStageOf(id);
if (columnId) query.stage = columnId; 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> </script>
@@ -440,23 +557,32 @@ function openActivity(id: string) {
:copy="ACTIVITY_BOARD_COPY" :copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart" @drag-start="onDragStart"
@drag-end="onDragEnd" @drag-end="onDragEnd"
@drag-over="onDragOver" @drag-over="onDragOver"
@drop="onDrop" @drop="onDrop"
@add-deal="emit('add', $event)" @add-deal="emit('add', $event)"
@open-deal="openActivity" @open-deal="openActivity"
@detail-deal="openFullActivity"
@menu-deal="onMenuActivity" @menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage" @rename-stage="onRenameStage"
@delete-stage="onDeleteStage" @delete-stage="onDeleteStage"
@move-stage="onMoveStage" @move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)" @column-drag-start="onColumnDragStart"
@column-drag-end="draggingColumnId = null; dropStageId = null" @column-drag-end="onColumnDragEnd"
@add-stage="onAddStage" @add-stage="onAddStage"
@load-more="loadMore" @load-more="loadMore"
/> />
<ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -17,6 +17,8 @@ const props = defineProps<{
activity: ActivityDto; activity: ActivityDto;
saving?: boolean; saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>; save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="layer" role="presentation"> <div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<div class="backdrop" aria-hidden="true" /> <div class="backdrop" aria-hidden="true" />
<section <section
class="sheet" class="sheet"
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
const props = defineProps<{ const props = defineProps<{
dueAt?: string | null; dueAt?: string | null;
saving?: boolean; saving?: boolean;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
<template> <template>
<Teleport to="body"> <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" /> <button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title"> <section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" /> <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 { import type {
ActivityFieldDef, ActivityFieldDef,
ActivityFieldType, ActivityFieldType,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,9 +18,11 @@ import {
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS, ACTIVITY_TYPE_LABELS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ACTIVITY_TYPE_ICON_LABELS, ACTIVITY_TYPE_ICON_LABELS,
newFieldKey, newFieldKey,
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>(); const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>(); 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']; const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string }; type FieldDraft = ActivityFieldDef & { optionsText: string };
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
notice.value = ''; 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( watch(
() => props.open, () => props.open,
async (open) => { async (open) => {
if (!open) return; if (!open) return;
error.value = ''; error.value = '';
notice.value = ''; notice.value = '';
tab.value = 'types';
await load(true); await load(true);
await prioritiesApi.load(true);
const first = types.value[0]; const first = types.value[0];
selectedId.value = null; selectedId.value = null;
draft.value = null; draft.value = null;
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
} }
function close() { 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'); emit('close');
} }
</script> </script>
@@ -291,6 +371,28 @@ function close() {
<template> <template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close"> <UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr"> <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"> <p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
@@ -479,6 +581,43 @@ function close() {
</section> </section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p> <p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div> </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> </div>
</UiModal> </UiModal>
</template> </template>
@@ -489,6 +628,78 @@ function close() {
gap: 0.9rem; 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 { .hint {
margin: 0; margin: 0;
color: #64748b; 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 { RouterLink } from 'vue-router';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { hasSectionPermission } from '@/lib/section-permissions';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui'; import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue'; import SmsComposeModal from '@/components/SmsComposeModal.vue';
@@ -25,9 +27,13 @@ type BirthdayMonth = {
items: BirthdayItem[]; items: BirthdayItem[];
}; };
const auth = useAuthStore();
const data = ref<BirthdayMonth | null>(null); const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false); const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null); const smsTarget = ref<BirthdayItem | null>(null);
const canSeeCustomers = computed(() =>
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
);
const groups = computed(() => { const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>(); const map = new Map<number, BirthdayItem[]>();
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
} }
onMounted(async () => { onMounted(async () => {
if (!canSeeCustomers.value) {
data.value = null;
return;
}
try { try {
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays'); const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
data.value = body.total > 0 ? body : null; data.value = body.total > 0 ? body : null;
+190 -51
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
FlagFilled, FlagFilled,
LeftOutlined,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { import {
@@ -18,10 +19,12 @@ import {
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars'; import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board'; import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -31,6 +34,9 @@ const props = withDefaults(
copy?: PipelineUiCopy; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selected?: boolean; selected?: boolean;
peeking?: boolean;
/** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */
previewing?: boolean;
}>(), }>(),
{ copy: () => DEAL_BOARD_COPY }, { copy: () => DEAL_BOARD_COPY },
); );
@@ -39,6 +45,7 @@ const emit = defineEmits<{
dragStart: [deal: PipelineDeal, event: DragEvent]; dragStart: [deal: PipelineDeal, event: DragEvent];
dragEnd: []; dragEnd: [];
open: [dealId: string]; open: [dealId: string];
detail: [dealId: string];
menu: [dealId: string]; menu: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -47,6 +54,11 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const activityOpen = ref(false);
const highlighted = computed(
() => props.selected || props.peeking || activityOpen.value,
);
const showAmount = computed( const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null, () => !props.copy.hideAmount && props.deal.amount != null,
); );
@@ -63,26 +75,98 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1), Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
); );
const showTrash = computed(() => props.selectMode && props.selected); const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = { const dateHint = computed(() => {
very_high: { label: 'خیلی زیاد', color: '#dc2626' }, if (!props.deal.dateIso) return props.deal.date;
high: { label: 'زیاد', color: '#ea580c' }, const d = new Date(props.deal.dateIso);
medium: { label: 'متوسط', color: '#eab308' }, if (Number.isNaN(d.getTime())) return props.deal.date;
low: { label: 'کم', color: '#16a34a' }, return formatJalaliNumeric(d);
very_low: { label: 'خیلی کم', color: '#94a3b8' }, });
}; const activityPriorities = useActivityPriorities();
const priorityFlag = computed(() => onMounted(() => {
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null, 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) return 'none';
if (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(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1, // تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو می‌کند؛ فقط سایه
transform: props.dragging ? 'scale(0.98)' : 'none', opacity: props.dragging ? 0.55 : 1,
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease', transition: 'opacity 120ms ease, box-shadow 120ms ease',
margin: 0, margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer, position: highlighted.value ? 'relative' : undefined,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary, zIndex: highlighted.value ? 2 : undefined,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary, 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> </script>
@@ -124,21 +208,29 @@ const cardStyle = computed(() => ({
</template> </template>
</a-button> </a-button>
<div :style="{ flex: 1, minWidth: 0 }"> <div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text <a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
ellipsis <a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
:style="{ <FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
display: 'block', </a-tooltip>
margin: 0, <a-typography-text
lineHeight: 1.35, ellipsis
cursor: 'pointer', :style="{
fontSize: `${token.fontSizeSM}px`, flex: 1,
fontWeight: 600, minWidth: 0,
}" display: 'block',
:title="deal.title" margin: 0,
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)" lineHeight: 1.35,
> cursor: 'pointer',
{{ deal.title }} fontSize: `${token.fontSizeSM}px`,
</a-typography-text> fontWeight: 600,
}"
:title="deal.title"
@click.stop="onTitleClick"
@dblclick.stop
>
{{ deal.title }}
</a-typography-text>
</a-flex>
<a-typography-text <a-typography-text
v-if="deal.description" v-if="deal.description"
type="secondary" type="secondary"
@@ -193,28 +285,75 @@ const cardStyle = computed(() => ({
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle"> <a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }} +{{ extraAssignees }}
</a-tag> </a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }"> <a-tooltip :title="dateHint">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" /> <a-typography-text
{{ deal.date }} :type="dueTone === 'none' ? 'secondary' : undefined"
</a-typography-text> :style="{
fontSize: `${token.fontSizeSM}px`,
color: dueTone !== 'none' ? dueToneColor : undefined,
}"
>
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-tooltip>
</a-space> </a-space>
<a-typography-text <a-space :size="4" align="center" :style="{ flexShrink: 0 }">
v-if="showAmount" <a-typography-text
strong v-if="showAmount"
dir="ltr" strong
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }" dir="ltr"
> :style="{ margin: 0, whiteSpace: 'nowrap' }"
{{ money.number }} >
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }"> {{ money.number }}
{{ money.unit }} <a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text> </a-typography-text>
</a-typography-text> <button
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`"> v-if="copy.quickView && !selectMode"
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }"> type="button"
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" /> 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> </span>
</a-tooltip> </a-space>
</a-flex> </a-flex>
</a-flex> </a-flex>
</a-card> </a-card>
</template> </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; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selectedIds?: Set<string>; 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<{ const emit = defineEmits<{
@@ -32,6 +34,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number]; drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string]; addDeal: [stageId: string];
openDeal: [dealId: string]; openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string]; menuDeal: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -109,6 +112,8 @@ function submitAdd() {
:copy="copy" :copy="copy"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="previewId"
:is-first="index === 0" :is-first="index === 0"
:is-last="index === stages.length - 1" :is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@@ -117,6 +122,7 @@ function submitAdd() {
@drop="(id, e, index) => emit('drop', id, e, index)" @drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)" @add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)" @open-deal="emit('openDeal', $event)"
@detail-deal="emit('detailDeal', $event)"
@menu-deal="emit('menuDeal', $event)" @menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@@ -26,8 +26,10 @@ const props = withDefaults(
copy?: PipelineUiCopy; copy?: PipelineUiCopy;
selectMode?: boolean; selectMode?: boolean;
selectedIds?: Set<string>; 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<{ const emit = defineEmits<{
@@ -37,6 +39,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number]; drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string]; addDeal: [stageId: string];
openDeal: [dealId: string]; openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string]; menuDeal: [dealId: string];
toggleSelect: [dealId: string]; toggleSelect: [dealId: string];
bulkDelete: []; bulkDelete: [];
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
:copy="copy" :copy="copy"
:select-mode="selectMode" :select-mode="selectMode"
:selected="selectedIds.has(deal.id)" :selected="selectedIds.has(deal.id)"
:peeking="peekId === deal.id"
:previewing="previewId === deal.id"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')" @drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)" @open="emit('openDeal', $event)"
@detail="emit('detailDeal', $event)"
@menu="emit('menuDeal', $event)" @menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@@ -15,11 +15,13 @@ import {
Space as ASpace, Space as ASpace,
Table as ATable, Table as ATable,
Tag as ATag, Tag as ATag,
Tooltip as ATooltip,
Typography, Typography,
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import type { import type {
PipelineAccent, PipelineAccent,
PipelineDeal, PipelineDeal,
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال'); 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 hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals')); const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
@@ -213,10 +222,12 @@ onUnmounted(() => {
</span> </span>
</template> </template>
<template v-else-if="column.key === 'date'"> <template v-else-if="column.key === 'date'">
<a-space :size="6"> <a-tooltip :title="dateHint(record.deal)">
<CalendarOutlined /> <a-space :size="6">
<span>{{ record.deal.date }}</span> <CalendarOutlined />
</a-space> <span>{{ record.deal.date }}</span>
</a-space>
</a-tooltip>
</template> </template>
<template v-else-if="column.key === 'actions'"> <template v-else-if="column.key === 'actions'">
<span :data-card-id="record.deal.id"> <span :data-card-id="record.deal.id">
@@ -334,10 +345,12 @@ onUnmounted(() => {
{{ row.stageTitle }} {{ row.stageTitle }}
</a-tag> </a-tag>
<a-flex justify="space-between" align="center"> <a-flex justify="space-between" align="center">
<a-space :size="6"> <a-tooltip :title="dateHint(row.deal)">
<CalendarOutlined /> <a-space :size="6">
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text> <CalendarOutlined />
</a-space> <a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
</a-tooltip>
<span v-if="!hideAmount" dir="ltr"> <span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text> <a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }"> <a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
@@ -24,9 +24,9 @@ import {
} from 'ant-design-vue'; } from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX, ROUTINES_CTX,
apiError, apiError,
localInputToIso, localInputToIso,
@@ -68,10 +68,10 @@ const form = reactive({
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityOptions = computed(() => const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map( activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
([value, label]) => ({ value, label }),
),
); );
const categoryOptions = computed(() => [ const categoryOptions = computed(() => [
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access'; import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali'; import { formatJalaliDayClock } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS, PRIORITY_VARIANTS,
ROUTINES_CTX, ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS, ROUTINE_ENTITY_LINK_LABELS,
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore(); const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager); const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines')); 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() { function startEdit() {
const t = task.value; const t = task.value;
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<div class="badges"> <div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge> <UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm"> <UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }} اولویت {{ activityPriorities.labelOf(task.priority) }}
</UiBadge> </UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge> <UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div> </div>
@@ -23,9 +23,9 @@ import {
const AMenuItem = AMenu.Item; const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS, ROUTINE_TASK_STATUS_LABELS,
entityRoute, entityRoute,
@@ -64,6 +64,9 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto]; action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>(); }>();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const done = computed(() => props.task.status === 'completed'); const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled'); const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status)); const open = computed(() => isOpenStatus(props.task.status));
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
<CheckOutlined /> {{ formatTime(task.completedAt) }} <CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text> </a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag> <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"> <a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }} {{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</a-tag> </a-tag>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue'; import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared'; import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => { const links = computed(() => {
const role = auth.user?.role; const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance'; 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) { function onChange(value: string | number) {
const next = String(value); const next = String(value);
if (next && next !== route.path) void router.push(next); if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template> <template>
<div <div
ref="scroller"
:style="{ :style="{
width: '100%', width: '100%',
overflowX: 'auto', overflowX: 'auto',
@@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue'; import {
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format'; import { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{ const props = defineProps<{
rows: TreasuryPartyBalanceDto[]; rows: TreasuryPartyBalanceDto[];
settledLabel: string; settledLabel: string;
}>(); }>();
const isMobile = useTreasuryMobile();
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const kindLabel: Record<string, string> = { const kindLabel: Record<string, string> = {
person: 'مشتری', person: 'مشتری',
company: 'شرکت', company: 'شرکت',
@@ -39,10 +49,43 @@ const columns = computed(() => [
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining), customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
}, },
]); ]);
function kindOf(record: TreasuryPartyBalanceDto) {
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
}
</script> </script>
<template> <template>
<a-list
v-if="isMobile"
:data-source="rows"
:locale="{ emptyText: 'مانده‌ای نیست.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-flex vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.partyName }}</a-typography-text>
<a-typography-text type="secondary">{{ kindOf(item) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مبلغ کل</a-typography-text>
<a-typography-text>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">{{ settledLabel }}</a-typography-text>
<a-typography-text>{{ toman(item.settledAmount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مانده</a-typography-text>
<a-typography-text strong>{{ toman(item.remaining) }}</a-typography-text>
</a-flex>
</a-flex>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else
:columns="columns" :columns="columns"
:data-source="rows" :data-source="rows"
:pagination="false" :pagination="false"
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup; const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind'); const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId'); const partyId = defineModel<string | undefined>('partyId');
@@ -156,12 +161,12 @@ onMounted(() => {
:filter-option="false" :filter-option="false"
:loading="loading" :loading="loading"
:value="selectValue" :value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر" :placeholder="placeholder"
style="width: 100%" style="width: 100%"
@search="onSearch" @search="onSearch"
@change="onChange" @change="onChange"
> >
<a-select-option value="other" label="سایر">سایر</a-select-option> <a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان"> <a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option <a-select-option
v-for="row in peopleOptions" v-for="row in peopleOptions"
+37 -2
View File
@@ -184,8 +184,43 @@ function onSizeChange(e: Event) {
@media (max-width: 900px) { @media (max-width: 900px) {
.ui-pagination { .ui-pagination {
flex-direction: column; flex-direction: column;
align-items: center; align-items: stretch;
gap: var(--sp-3); gap: 0.65rem;
}
.pagination-info {
width: 100%;
flex-wrap: nowrap;
justify-content: space-between;
gap: 0.35rem;
font-size: 0.78rem;
}
.page-count {
flex: 0 1 auto;
white-space: nowrap;
}
.size-picker {
margin-inline-start: 0;
flex: 0 0 auto;
gap: 0.25rem;
white-space: nowrap;
}
.size-picker span:last-child {
display: none;
}
.size-picker select {
height: 28px;
min-width: 3.25rem;
padding: 0 0.3rem;
}
.pagination-controls {
width: 100%;
justify-content: center;
} }
} }
</style> </style>
+12 -6
View File
@@ -21,6 +21,7 @@ import type {
import { canManageSettings, canViewAllRecords } from '@/lib/roles'; import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { import {
canOpenPath, canOpenPath,
firstAllowedFinancePath,
hasSectionPermission, hasSectionPermission,
sectionPermissionForPath, sectionPermissionForPath,
} from '@/lib/section-permissions'; } from '@/lib/section-permissions';
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
}) { }) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false; if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? []; const perms = auth.user?.permissions ?? [];
const canTreasury = auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| perms.includes('treasury')
|| perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) { if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury; return (
moduleAllowed(card.module ?? 'treasury')
&& Boolean(firstAllowedFinancePath(auth.user?.role, perms))
);
} }
const section = card.to ? sectionPermissionForPath(card.to) : null; const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section const hasSection = section
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null; if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value); const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null; if (!hub) return null;
const cards = hub.cards.filter((card) => cardAllowedForNav(card)); const financeTo = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
const cards = hub.cards
.filter((card) => cardAllowedForNav(card))
.map((card) => {
if (!financeTo || !card.to.startsWith('/finance') || card.to === financeTo) return card;
return { ...card, to: financeTo };
});
if ( if (
hub.key === 'customers' && hub.key === 'customers' &&
isMobileNav.value && isMobileNav.value &&
+43
View File
@@ -0,0 +1,43 @@
import type { ActivityDto, ActivityEditPolicy } from '@mento/shared';
type ActivityAccessUser = {
id?: string | null;
role?: string | null;
} | null | undefined;
type ActivityAccessRow = Pick<
ActivityDto,
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
> | null | undefined;
function isAssignee(
activity: NonNullable<ActivityAccessRow>,
userId: string,
): boolean {
if (activity.assignee?.id === userId) return true;
return (activity.assignees ?? []).some((a) => a.id === userId);
}
/** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */
export function canMutateActivity(
activity: ActivityAccessRow,
user: ActivityAccessUser,
): boolean {
if (!activity || !user?.id) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
if (activity.createdBy?.id === user.id) return true;
const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator';
if (policy !== 'assignees') return false;
return isAssignee(activity, user.id);
}
/** ثبت نظر و تعویق برای همهٔ مسئولان (حتی وقتی ویرایش فقط با ایجادکننده است) */
export function canCollaborateOnActivity(
activity: ActivityAccessRow,
user: ActivityAccessUser,
): boolean {
if (!activity || !user?.id) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
if (activity.createdBy?.id === user.id) return true;
return isAssignee(activity, user.id);
}
+67
View File
@@ -0,0 +1,67 @@
import { computed, ref } from 'vue';
import type { ActivityPriority, ActivityPrioritySetting } from '@mento/shared';
import {
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared';
import { api } from '@/lib/api';
const items = ref<ActivityPrioritySetting[]>([...DEFAULT_ACTIVITY_PRIORITY_SETTINGS]);
const loaded = ref(false);
let pending: Promise<void> | null = null;
/** تنظیمات اولویت فعالیت/روتین (کش مشترک) */
export function useActivityPriorities() {
async function load(force = false): Promise<void> {
if (loaded.value && !force) return;
if (pending && !force) return pending;
pending = api
.get<ActivityPrioritySetting[]>('/activities/priorities')
.then(({ data }) => {
items.value = parseActivityPrioritySettings(data);
loaded.value = true;
})
.catch(() => {
if (!loaded.value) items.value = [...DEFAULT_ACTIVITY_PRIORITY_SETTINGS];
})
.finally(() => {
pending = null;
});
return pending;
}
const byKey = computed(() => {
const map = new Map<ActivityPriority, ActivityPrioritySetting>();
for (const row of items.value) map.set(row.key, row);
return map;
});
const labels = computed(() => {
const out = {} as Record<ActivityPriority, string>;
for (const row of items.value) out[row.key] = row.label;
return out;
});
const colors = computed(() => {
const out = {} as Record<ActivityPriority, string>;
for (const row of items.value) out[row.key] = row.color;
return out;
});
function labelOf(key: ActivityPriority | string | null | undefined): string {
if (!key) return '—';
return byKey.value.get(key as ActivityPriority)?.label ?? String(key);
}
function colorOf(key: ActivityPriority | string | null | undefined): string {
if (!key) return '#94a3b8';
return byKey.value.get(key as ActivityPriority)?.color ?? '#94a3b8';
}
function setLocal(next: ActivityPrioritySetting[]) {
items.value = parseActivityPrioritySettings(next);
loaded.value = true;
}
return { items, byKey, labels, colors, loaded, load, labelOf, colorOf, setLocal };
}
+161
View File
@@ -0,0 +1,161 @@
import { jalaliToDate } from '@/lib/jalali';
export type ActivityThreadEntry = {
at: Date | null;
dateLabel: string;
timeLabel: string;
author: string;
body: string;
/** شناسهٔ فایل‌های چسبیده به این یادداشت (عکس/صوت/فایل) */
attachmentIds?: string[];
};
const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/;
const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i;
const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000;
/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل می‌کند. */
export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null {
const text = (raw ?? '').replace(/\r\n/g, '\n').trim();
if (!text) return null;
const entries: ActivityThreadEntry[] = [];
const preamble: string[] = [];
let current: ActivityThreadEntry | null = null;
let sawStamp = false;
for (const line of text.split('\n')) {
const trimmed = line.trim();
const stamp = trimmed.match(STAMP);
if (stamp) {
sawStamp = true;
if (current) entries.push(trimEntry(current));
current = {
at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''),
dateLabel: (stamp[1] ?? '').trim(),
timeLabel: (stamp[2] ?? '').trim(),
author: (stamp[3] ?? '').trim(),
body: '',
attachmentIds: [],
};
continue;
}
const attach = trimmed.match(ATTACHMENTS);
if (attach && current) {
current.attachmentIds = attach[1]!
.split(',')
.map((id) => id.trim())
.filter(Boolean);
continue;
}
if (current) current.body += `${current.body ? '\n' : ''}${line}`;
else preamble.push(line);
}
if (current) entries.push(trimEntry(current));
if (!sawStamp) return null;
const head = preamble.join('\n').trim();
if (head) {
entries.unshift({
at: null,
dateLabel: '',
timeLabel: '',
author: '',
body: head,
attachmentIds: [],
});
}
return entries;
}
/** برگرداندن متن ذخیره‌شونده از نظرهای جدا. */
export function serializeActivityThread(entries: ActivityThreadEntry[]): string {
return entries
.map((entry) => {
const ids = (entry.attachmentIds ?? []).filter(Boolean);
const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : '';
if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim();
const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`;
return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`;
})
.filter((chunk) => chunk.trim())
.join('\n\n');
}
/** فایل‌ها را به یادداشت‌ها می‌چسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */
export function bindThreadMedia<T extends { id: string; createdAt: string }>(
entries: ActivityThreadEntry[],
images: T[],
windowMs = COMMENT_MEDIA_WINDOW_MS,
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; loose: T[] } {
const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] }));
const byId = new Map(images.map((image) => [image.id, image]));
const used = new Set<string>();
blocks.forEach((block) => {
for (const id of block.attachmentIds ?? []) {
const image = byId.get(id);
if (!image || used.has(id)) continue;
block.media.push(image);
used.add(id);
}
});
for (const image of images) {
if (used.has(image.id)) continue;
const at = new Date(image.createdAt).getTime();
if (Number.isNaN(at)) continue;
let best = -1;
let bestDelta = Number.POSITIVE_INFINITY;
blocks.forEach((block, index) => {
if (!block.at) return;
const delta = block.at.getTime() - at;
if (delta >= -60_000 && delta < bestDelta) {
best = index;
bestDelta = delta;
}
});
if (best >= 0 && bestDelta <= windowMs) {
blocks[best]!.media.push(image);
used.add(image.id);
}
}
const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY;
const preamble = blocks.find((block) => !block.at);
const loose: T[] = [];
for (const image of images) {
if (used.has(image.id)) continue;
const at = new Date(image.createdAt).getTime();
if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image);
else loose.push(image);
}
return { blocks, loose };
}
function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry {
return {
...entry,
body: entry.body.trim(),
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
};
}
function latinDigits(value: string): string {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const faIndex = fa.indexOf(ch);
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
});
}
function stampToDate(dateLabel: string, timeLabel: string): Date | null {
const date = latinDigits(dateLabel).match(/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
if (!date) return null;
const at = jalaliToDate(Number(date[1]), Number(date[2]), Number(date[3]));
if (Number.isNaN(at.getTime())) return null;
const time = latinDigits(timeLabel).match(/(\d{1,2}):(\d{2})/);
if (time) at.setHours(Number(time[1]), Number(time[2]), 0, 0);
return at;
}
+11
View File
@@ -226,6 +226,17 @@ export function formatRelativeContact(iso: string | null | undefined): string {
return `${toFaDigits(days)} روز پیش`; return `${toFaDigits(days)} روز پیش`;
} }
/** تاریخ نسبی تقویمی برای کارت‌ها: امروز / ۳ روز عقب افتاده / ۵ روز مانده */
export function formatRelativeDayLabel(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const daysAhead = calendarDaysBetween(new Date(), d);
if (daysAhead === 0) return 'امروز';
if (daysAhead > 0) return `${toFaDigits(daysAhead)} روز مانده`;
return `${toFaDigits(-daysAhead)} روز عقب افتاده`;
}
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */ /** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
export function formatNextSlot(iso: string | null | undefined): string { export function formatNextSlot(iso: string | null | undefined): string {
if (!iso) return '—'; if (!iso) return '—';
+32 -3
View File
@@ -1,6 +1,11 @@
import { currencySuffix } from '@/lib/money'; import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search'; import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared'; import type {
ActivityPriority,
ActivityStatus,
DealActivityCue,
DealActivitySummaryDto,
} from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate'; export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -10,6 +15,8 @@ export type PipelineDeal = {
description: string; description: string;
amount: number | null; amount: number | null;
date: string; date: string;
/** ISO برای tooltip تاریخ مطلق */
dateIso?: string;
initials: string; initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */ /** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string; stageId?: string;
@@ -20,10 +27,18 @@ export type PipelineDeal = {
assigneeNames?: string[]; assigneeNames?: string[];
ownerId?: string | null; ownerId?: string | null;
assigneeIds?: string[]; assigneeIds?: string[];
personId?: string | null;
companyId?: string | null;
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */ /** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>; searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */ /** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority; priority?: ActivityPriority;
/** وضعیت فعالیت (برای کارت کانبان فعالیت: انجام‌شده و …) */
activityStatus?: ActivityStatus;
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
activityCue?: DealActivityCue;
/** خلاصه فعالیت‌ها برای پاپ‌آپ دکمهٔ تقویم */
activitySummaries?: DealActivitySummaryDto[];
}; };
export type PipelineUiCopy = { export type PipelineUiCopy = {
@@ -34,6 +49,10 @@ export type PipelineUiCopy = {
itemMenuAria: string; itemMenuAria: string;
listColTitle: string; listColTitle: string;
hideAmount?: boolean; hideAmount?: boolean;
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه به‌جای ورود مستقیم به جزئیات */
quickView?: boolean;
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
quickActivity?: boolean;
}; };
export const DEAL_BOARD_COPY: PipelineUiCopy = { export const DEAL_BOARD_COPY: PipelineUiCopy = {
@@ -43,6 +62,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
addItemAria: 'افزودن معامله به این مرحله', addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله', itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله', listColTitle: 'معامله',
quickActivity: true,
}; };
export const PROJECT_BOARD_COPY: PipelineUiCopy = { export const PROJECT_BOARD_COPY: PipelineUiCopy = {
@@ -63,6 +83,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemMenuAria: 'گزینه‌های فعالیت', itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت', listColTitle: 'فعالیت',
hideAmount: true, hideAmount: true,
quickView: true,
}; };
export type PipelineStage = { export type PipelineStage = {
@@ -182,8 +203,10 @@ export function mapBoardToStages(
expectedCloseAt: string | null; expectedCloseAt: string | null;
createdAt: string; createdAt: string;
promotedFromPriorAt: string | null; promotedFromPriorAt: string | null;
person: { fullName: string } | null; activityCue?: DealActivityCue;
company: { name: string } | null; activitySummaries?: DealActivitySummaryDto[];
person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null; owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>; assignees?: Array<{ id: string; fullName: string }>;
}>; }>;
@@ -226,6 +249,9 @@ export function mapBoardToStages(
description, description,
amount: deal.amount ?? 0, amount: deal.amount ?? 0,
date: formatDate(dateIso), date: formatDate(dateIso),
dateIso,
activityCue: deal.activityCue ?? 'none',
activitySummaries: deal.activitySummaries ?? [],
initials: initialsFromTitle( initials: initialsFromTitle(
deal.company?.name || deal.title, deal.company?.name || deal.title,
), ),
@@ -236,6 +262,8 @@ export function mapBoardToStages(
assigneeNames, assigneeNames,
ownerId: deal.owner?.id ?? null, ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id), assigneeIds: (deal.assignees ?? []).map((a) => a.id),
personId: deal.person?.id ?? null,
companyId: deal.company?.id ?? null,
searchFields: [ searchFields: [
deal.notes, deal.notes,
deal.company?.name, deal.company?.name,
@@ -291,6 +319,7 @@ export function mapProjectBoardToStages(
'', '',
amount: null, amount: null,
date: formatDate(card.createdAt), date: formatDate(card.createdAt),
dateIso: card.createdAt,
initials: initialsFromTitle( initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title, card.company?.name || card.person?.fullName || card.title,
), ),
+1
View File
@@ -29,6 +29,7 @@ export {
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info'; type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
/** پیش‌فرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
export const PRIORITY_LABELS: Record<ActivityPriority, string> = { export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم', very_low: 'خیلی کم',
low: 'کم', low: 'کم',
+24
View File
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
return treasurySectionAllowed(role, permissions, section); return treasurySectionAllowed(role, permissions, section);
} }
const FINANCE_ENTRIES: Array<{ path: string; section: TreasurySectionKey }> = [
{ path: '/finance', section: 'treasury_dashboard' },
{ path: '/finance/incomes', section: 'treasury_incomes' },
{ path: '/finance/expenses', section: 'treasury_expenses' },
{ path: '/finance/receivables', section: 'treasury_receivables' },
{ path: '/finance/accounts', section: 'treasury_accounts' },
{ path: '/finance/payroll', section: 'treasury_payroll' },
{ path: '/finance/recurring', section: 'treasury_recurring' },
{ path: '/finance/reports', section: 'treasury_reports' },
{ path: '/finance/audit', section: 'treasury_audit' },
{ path: '/finance/categories', section: 'treasury_categories' },
];
/** اولین صفحهٔ مالی که این نقش واقعاً می‌تواند باز کند. */
export function firstAllowedFinancePath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
): string | null {
for (const entry of FINANCE_ENTRIES) {
if (treasurySectionAllowed(role, permissions, entry.section)) return entry.path;
}
return null;
}
export function canOpenPath( export function canOpenPath(
role: UserRole | string | null | undefined, role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined, permissions: readonly string[] | null | undefined,
+12 -10
View File
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared'; import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth'; import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions'; import {
canOpenFinancePath,
canOpenPath,
firstAllowedFinancePath,
sectionPermissionForPath,
} from '@/lib/section-permissions';
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -330,7 +335,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد', treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.', treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
}, },
}, },
{ {
@@ -350,7 +356,8 @@ export const router = createRouter({
meta: { meta: {
roles: ['owner', 'admin'] as UserRole[], roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی', treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
}, },
}, },
{ {
@@ -681,13 +688,8 @@ router.beforeEach((to) => {
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) { if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true; return true;
} }
const exactHome = to.path === '/finance' || to.path === '/finance/'; const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
if ( if (next && next !== to.path) return { path: next };
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
} }
return { name: 'home' }; return { name: 'home' };
} }
+6
View File
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) void hydrate(); if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return { return {
token, token,
user, user,
+103 -44
View File
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue'; import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue'; import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { import {
ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_ALIASES,
@@ -52,7 +53,10 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles'; import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { apiError } from '@/lib/routines';
import { message } from 'ant-design-vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize'; import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -177,6 +181,7 @@ const boardDeleteOpen = ref(false);
async function onTypesChanged() { async function onTypesChanged() {
await activityTypes.load(true); await activityTypes.load(true);
await useActivityPriorities().load(true);
kanbanReload.value += 1; kanbanReload.value += 1;
await refreshAll(); await refreshAll();
} }
@@ -201,6 +206,7 @@ const page = computed(() => {
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const notice = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null); const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : ''); const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]); const users = ref<OrgUserDto[]>([]);
@@ -445,23 +451,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
return query; return query;
} }
function activityRoute(id: string, edit = false, columnStageId?: string) { function activityRoute(id: string, columnStageId?: string) {
if (!edit) {
return {
name: 'activity-detail' as const,
params: { id },
query: deskDetailQuery(columnStageId),
};
}
const query: Record<string, string> = {};
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
return { return {
name: 'activity-edit' as const, name: 'activity-detail' as const,
params: { id }, params: { id },
query, query: deskDetailQuery(columnStageId),
}; };
} }
@@ -837,6 +831,30 @@ function onFacetDone() {
openCol.value = null; openCol.value = null;
} }
function assigneePeopleOf(item: ActivityDto) {
if (item.assignees?.length) return item.assignees;
return item.assignee ? [item.assignee] : [];
}
function needsEveryone(item: ActivityDto) {
return item.completionMode === 'all' && assigneePeopleOf(item).length > 1;
}
function canMarkShare(item: ActivityDto) {
if (item.status !== 'open') return false;
if (!needsEveryone(item)) return true;
const mine = auth.user?.id;
if (!mine) return false;
return (
assigneePeopleOf(item).some((person) => person.id === mine) &&
!item.completedByIds?.includes(mine)
);
}
function completeLabel(item: ActivityDto) {
return needsEveryone(item) ? 'انجام سهم من' : 'انجام شد';
}
async function complete(id: string) { async function complete(id: string) {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
@@ -913,10 +931,27 @@ function clearSelect() {
selectedIds.value = new Set(); selectedIds.value = new Set();
} }
function canDeleteActivity(item: ActivityDto | null | undefined) {
return canMutateActivity(item, auth.user);
}
function activityById(id: string) {
return (
items.value.find((row) => row.id === id) ||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
);
}
function askDeleteActivity(id: string, title?: string) { function askDeleteActivity(id: string, title?: string) {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null; boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
deleteSingleTitle.value = title?.trim() || activityTitle(id); deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id]; deleteActivityIds.value = [id];
} }
@@ -926,8 +961,24 @@ function askBulkDelete() {
menuId.value = null; menuId.value = null;
boardMenuId.value = null; boardMenuId.value = null;
boardMenuActivity.value = null; boardMenuActivity.value = null;
const selected = [...selectedIds.value];
const allowed = selected.filter((id) => {
const row = activityById(id);
return !row || canDeleteActivity(row);
});
const blocked = selected.length - allowed.length;
if (!allowed.length) {
message.warning('شما مجوز حذف این فعالیت‌ها را ندارید.');
error.value = 'شما مجوز حذف این فعالیت‌ها را ندارید.';
return;
}
if (blocked > 0) {
message.warning(
`${toFaDigits(blocked)} مورد به‌خاطر نبود مجوز حذف کنار گذاشته شد.`,
);
}
deleteSingleTitle.value = ''; deleteSingleTitle.value = '';
deleteActivityIds.value = [...selectedIds.value]; deleteActivityIds.value = allowed;
} }
async function confirmDeleteActivities() { async function confirmDeleteActivities() {
@@ -937,16 +988,24 @@ async function confirmDeleteActivities() {
error.value = ''; error.value = '';
try { try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`))); const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length; const failed = results.filter((r) => r.status === 'rejected');
deleteActivityIds.value = []; deleteActivityIds.value = [];
clearSelect(); clearSelect();
kanbanReload.value += 1; kanbanReload.value += 1;
await refreshAll(); await refreshAll();
if (failed) { if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value = error.value =
failed === ids.length failed.length === ids.length
? 'حذف فعالیت‌ها ناموفق بود.' ? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: `${toFaDigits(failed)} مورد حذف نشد.`; : detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
} }
} finally { } finally {
activityDeleting.value = false; activityDeleting.value = false;
@@ -1160,11 +1219,11 @@ onUnmounted(() => {
variant="ghost" variant="ghost"
size="sm" size="sm"
type="button" type="button"
title="انواع فعالیت و فیلدها" title="انواع فعالیت و اولویت ها"
@click="typesManagerOpen = true" @click="typesManagerOpen = true"
> >
<UiIcon name="sliders" :size="14" /> <UiIcon name="sliders" :size="14" />
<span class="manage-label">انواع فعالیت و فیلدها</span> <span class="manage-label">انواع فعالیت و اولویت ها</span>
</UiButton> </UiButton>
<UiButton <UiButton
v-if="canManageStages" v-if="canManageStages"
@@ -1323,7 +1382,7 @@ onUnmounted(() => {
<hr class="board-menu-sep" /> <hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true"> <button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" /> <UiIcon name="sliders" :size="16" />
انواع فعالیت و فیلدها انواع فعالیت و اولویت ها
</button> </button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true"> <button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" /> <UiIcon name="settings" :size="16" />
@@ -1503,6 +1562,7 @@ onUnmounted(() => {
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-if="notice" class="ok">{{ notice }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p> <p v-if="importMsg" class="ok">{{ importMsg }}</p>
<div v-if="selectMode" class="select-bar"> <div v-if="selectMode" class="select-bar">
@@ -1532,6 +1592,7 @@ onUnmounted(() => {
@stages-changed="onStagesChanged" @stages-changed="onStagesChanged"
@add="openAddInStage" @add="openAddInStage"
@error="(msg: string) => (error = msg)" @error="(msg: string) => (error = msg)"
@notice="(msg: string) => (notice = msg)"
@menu-deal="onBoardMenu" @menu-deal="onBoardMenu"
@toggle-select="toggleSelect" @toggle-select="toggleSelect"
@bulk-delete="askBulkDelete" @bulk-delete="askBulkDelete"
@@ -1749,16 +1810,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)"> <RouterLink :to="activityRoute(item.id)">
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)"> <button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری انتقال به کار جاری
</button> </button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)"> <button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
انجام شد {{ completeLabel(item) }}
</button> </button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)"> <button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف حذف
</button> </button>
</UiDropdownMenu> </UiDropdownMenu>
@@ -1839,16 +1902,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)"> <RouterLink :to="activityRoute(item.id)">
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)"> <button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری انتقال به کار جاری
</button> </button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)"> <button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
انجام شد {{ completeLabel(item) }}
</button> </button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)"> <button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف حذف
</button> </button>
</UiDropdownMenu> </UiDropdownMenu>
@@ -1895,18 +1960,11 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button> <button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink <RouterLink
role="menuitem" role="menuitem"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)" :to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''" @click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
> >
مشاهده مشاهده
</RouterLink> </RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button <button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)" v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button" type="button"
@@ -1916,6 +1974,7 @@ onUnmounted(() => {
انتقال به کار جاری انتقال به کار جاری
</button> </button>
<button <button
v-if="canDeleteActivity(boardMenuActivity)"
type="button" type="button"
role="menuitem" role="menuitem"
class="danger" class="danger"
File diff suppressed because it is too large Load Diff
+136 -30
View File
@@ -32,6 +32,7 @@ import {
UiTextarea, UiTextarea,
} from '@/components/ui'; } from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue'; import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type { import type {
ActivityBoardDto, ActivityBoardDto,
@@ -89,6 +90,8 @@ const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? ''); const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes(); const activityTypes = useActivityTypes();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const title = ref(''); const title = ref('');
const type = ref<ActivityType | ''>(''); const type = ref<ActivityType | ''>('');
const typeDefId = ref(''); const typeDefId = ref('');
@@ -129,6 +132,8 @@ const personId = ref('');
const leadId = ref(''); const leadId = ref('');
const dealId = ref(''); const dealId = ref('');
const assigneeIds = ref<string[]>([]); const assigneeIds = ref<string[]>([]);
const completionMode = ref<'any' | 'all'>('any');
const editPolicy = ref<'creator' | 'assignees'>('creator');
const isDone = ref(false); const isDone = ref(false);
const needsNote = ref(false); const needsNote = ref(false);
const reminderAt = ref(''); const reminderAt = ref('');
@@ -269,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
return false; return false;
} }
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [ const priorityOptions = computed(() =>
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' }, activityPriorities.items.value.map((row) => ({
{ value: 'high', label: 'زیاد', tone: 'high' }, value: row.key,
{ value: 'medium', label: 'متوسط', tone: 'medium' }, label: row.label,
{ value: 'low', label: 'کم', tone: 'low' }, color: row.color,
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' }, })),
]; );
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null); const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
const typeFields = computed(() => typeMeta.value?.fields ?? []); const typeFields = computed(() => typeMeta.value?.fields ?? []);
@@ -400,7 +405,7 @@ const duePreview = computed(() => {
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده')); const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
const priorityText = computed( const priorityText = computed(
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—', () => priorityOptions.value.find((p) => p.value === priority.value)?.label || '—',
); );
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت')); const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value)); const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
@@ -440,6 +445,8 @@ function resetForm() {
personId.value = ''; personId.value = '';
dealId.value = ''; dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : []; assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
completionMode.value = 'any';
editPolicy.value = 'creator';
pickedRoleIds.value = []; pickedRoleIds.value = [];
pickedTeamIds.value = []; pickedTeamIds.value = [];
isDone.value = false; isDone.value = false;
@@ -528,6 +535,8 @@ async function load() {
: auth.user?.id : auth.user?.id
? [auth.user.id] ? [auth.user.id]
: []; : [];
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
refreshPickedGroups(); refreshPickedGroups();
isDone.value = data.status === 'done'; isDone.value = data.status === 'done';
await loadSavedPhotos(data); await loadSavedPhotos(data);
@@ -541,6 +550,16 @@ async function load() {
personId.value = incomingPerson; personId.value = incomingPerson;
} }
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : ''; leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
const incomingDeal =
typeof route.query.dealId === 'string' ? route.query.dealId : '';
if (incomingDeal) {
dealId.value = incomingDeal;
const linkedDeal = deals.value.find((d) => d.id === incomingDeal);
if (linkedDeal?.person?.id && !personId.value) {
await ensurePersonLoaded(linkedDeal.person.id);
personId.value = linkedDeal.person.id;
}
}
const incomingDef = const incomingDef =
typeof route.query.typeDefId === 'string' typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId) ? activityTypes.byId.value.get(route.query.typeDefId)
@@ -605,6 +624,8 @@ function payload(): CreateActivityRequest {
leadId: leadId.value || null, leadId: leadId.value || null,
assigneeIds: [...assigneeIds.value], assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null, assigneeId: assigneeIds.value[0] || null,
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
editPolicy: editPolicy.value,
}; };
} }
@@ -978,7 +999,9 @@ async function submit(next: 'detail' | 'new' = 'detail') {
savedId = data.id; savedId = data.id;
createdActivityId.value = data.id; createdActivityId.value = data.id;
if (isDone.value) { if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`); await api.patch(`/activities/${savedId}/complete`, undefined, {
params: { force: '1' },
});
} }
} else { } else {
await api.patch(`/activities/${savedId}`, { await api.patch(`/activities/${savedId}`, {
@@ -1269,6 +1292,20 @@ watch([id, isNew], () => void load(), { immediate: true });
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="assigneeIds.length > 1" class="completion-mode">
<span>نحوهٔ انجام</span>
<label>
<input v-model="completionMode" type="radio" value="any" />
با انجام یکی کافی است
</label>
<label>
<input v-model="completionMode" type="radio" value="all" />
همهٔ مسئول‌ها باید انجام دهند
</label>
<p v-if="completionMode === 'all'" class="completion-hint">
هر کدام از همین افراد باید جداگانه سهم خودش را انجام دهد.
</p>
</div>
<small class="hint"> <small class="hint">
{{ {{
canAssignOthers canAssignOthers
@@ -1302,6 +1339,29 @@ watch([id, isNew], () => void load(), { immediate: true });
</div> </div>
</div> </div>
<div class="card">
<div class="field">
<label>ویرایش و حذف</label>
<div class="completion-mode">
<label>
<input v-model="editPolicy" type="radio" value="creator" />
فقط خودم (ایجادکننده)
</label>
<label>
<input v-model="editPolicy" type="radio" value="assignees" />
همهٔ مسئولان
</label>
</div>
<small class="hint">
{{
editPolicy === 'assignees'
? 'مسئولان هم می‌توانند این فعالیت را ویرایش یا حذف کنند.'
: 'فقط ایجادکننده می‌تواند این فعالیت را ویرایش یا حذف کند.'
}}
</small>
</div>
</div>
<div class="card"> <div class="card">
<div class="field"> <div class="field">
<div class="label-row"> <div class="label-row">
@@ -1385,7 +1445,13 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="card"> <div class="card">
<p class="pri-label">اولویت</p> <p class="pri-label">اولویت</p>
<div class="priorities"> <div class="priorities">
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]"> <label
v-for="opt in priorityOptions"
:key="opt.value"
class="pri"
:class="{ on: priority === opt.value }"
:style="{ color: opt.color }"
>
<input v-model="priority" type="radio" :value="opt.value" /> <input v-model="priority" type="radio" :value="opt.value" />
<UiIcon name="flag" :size="16" /> <UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span> <span>{{ opt.label }}</span>
@@ -1799,6 +1865,66 @@ watch([id, isNew], () => void load(), { immediate: true });
font-size: 0.75rem; font-size: 0.75rem;
} }
.completion-mode {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
}
.completion-mode > span {
width: 100%;
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 600;
}
.completion-mode label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.2rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
}
.completion-mode label:has(input:checked) {
border-color: #14b8a6;
background: #f0fdfa;
color: #0f766e;
}
.completion-mode input {
width: 14px;
height: 14px;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
flex: 0 0 14px;
accent-color: #0d9488;
background: transparent;
}
.completion-hint {
width: 100%;
margin: 0.15rem 0 0;
color: #64748b;
font-size: 0.75rem;
line-height: 1.5;
}
.assignee-chips { .assignee-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2248,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0; flex-shrink: 0;
} }
.pri.very-high {
color: #dc2626;
}
.pri.high {
color: #ea580c;
}
.pri.medium {
color: #eab308;
}
.pri.low {
color: #16a34a;
}
.pri.very-low {
color: #94a3b8;
}
.pri.on span { .pri.on span {
font-weight: 600; font-weight: 600;
} }

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