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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30
123 changed files with 11312 additions and 1942 deletions
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
@@ -0,0 +1,28 @@
-- مسئولان اختیاری روی گروه‌های مشتری
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "smsTemplates" JSONB NOT NULL DEFAULT '[]';
+27
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled
}
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority {
low
medium
@@ -151,6 +162,10 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// قالب‌های آمادهٔ پیامک دستی ([{ id, name, body }, …])
smsTemplates Json @default("[]")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime?
isActive Boolean @default(true)
@@ -295,6 +310,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
@@ -565,6 +581,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[]
companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -883,6 +901,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime?
completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([])
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -2447,8 +2471,11 @@ model FinancialPayroll {
period String
baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2)
dueDate DateTime
notes String?
@@ -7,6 +7,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
Res,
StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto,
UpdateActivityBoardDto,
UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto,
UpdateActivityTypeDto,
} from './dto';
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true };
}
@Get('priorities')
listPriorities(@CurrentUser() user: Actor) {
return this.typesService.listPriorities(user.organizationId);
}
@Put('priorities')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
return this.typesService.updatePriorities(user.organizationId, dto.items);
}
@Get('types')
listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId);
@@ -196,6 +210,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
);
@@ -232,6 +247,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -265,6 +281,7 @@ export class ActivitiesController {
title: query.title,
customer: query.customer,
due: query.due,
priority: query.priority,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -438,8 +455,16 @@ export class ActivitiesController {
}
@Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
complete(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Query('force') force?: string,
@Query('undo') undo?: string,
) {
return this.activitiesService.complete(user.organizationId, id, user, {
force: force === '1' || force === 'true',
undo: undo === '1' || undo === 'true',
});
}
@Delete(':id')
+283 -30
View File
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
function assigneeIdList(row: {
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
}): string[] {
const ids = row.assignees?.map((item) => item.id) ?? [];
if (ids.length) return [...new Set(ids)];
return row.assigneeId ? [row.assigneeId] : [];
}
function canForceClose(
actor: { userId: string; role: string },
createdById: string | null | undefined,
): boolean {
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
}
function canMutateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
editPolicy?: 'creator' | 'assignees' | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
return assigneeIdList(row).includes(actor.userId);
}
/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */
function canCollaborateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
return assigneeIdList(row).includes(actor.userId);
}
/** فقط dueAt / description → تعویق و ثبت نظر */
function isCollaboratorPatch(dto: UpdateActivityRequest): boolean {
const keys = Object.keys(dto).filter(
(key) => (dto as Record<string, unknown>)[key] !== undefined,
);
return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description');
}
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
}
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -64,6 +123,7 @@ type ListFacets = {
customer?: string;
/** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset';
priority?: ActivityDto['priority'];
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
};
@@ -82,6 +142,9 @@ type ActivityRow = {
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string;
promotedFromPriorAt?: Date | null;
tags: string[];
@@ -186,22 +249,39 @@ export class ActivitiesService {
return { stages, types };
}
/** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */
private deskScope(
actor: ActivityActor,
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput {
if (desk !== 'mine') return activityAccessWhere(actor);
return {
AND: [
{
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
},
{
NOT: {
completionMode: 'all',
status: 'open',
completedByIds: { has: actor.userId },
},
},
],
};
}
async summary(
organizationId: string,
actor: ActivityActor,
desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
@@ -534,8 +614,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
select: {
status: true,
completedAt: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (
stage.kind === 'done' &&
existing?.completionMode === 'all' &&
assigneeIdList(existing).length > 1 &&
!canForceClose(actor, existing.createdById)
) {
return this.complete(organizationId, id, actor);
}
const row = await this.prisma.activity.update({
where: { id },
data: {
@@ -550,6 +646,9 @@ export class ActivitiesService {
: stage.kind === 'open'
? null
: undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
},
include,
});
@@ -675,6 +774,9 @@ export class ActivitiesService {
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
: undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId,
},
include,
@@ -743,8 +845,31 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
select: {
status: true,
type: true,
typeDefId: true,
fields: true,
participantIds: true,
completionMode: true,
completedByIds: true,
editPolicy: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const collabOnly = isCollaboratorPatch(dto);
if (collabOnly) {
if (!canCollaborateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید');
}
} else if (!canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) {
@@ -812,6 +937,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
where: { id, organizationId },
@@ -839,6 +965,7 @@ export class ActivitiesService {
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +979,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
}
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
if (modeChanged) data.completionMode = nextMode;
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
data.editPolicy = dto.editPolicy;
}
const prevAssigneeIds = assigneeIdList(existing ?? {});
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
const explicitDone = dto.status === 'done';
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
const explicitCancel = dto.status === 'canceled';
if (explicitDone) {
data.status = 'done';
data.completedAt = new Date();
} else if (explicitReopen) {
data.status = 'open';
data.completedAt = null;
data.completedByIds = [];
} else if (explicitCancel) {
data.status = 'canceled';
} else if (nextMode === 'any' && modeChanged) {
data.completedByIds = [];
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
data.completedByIds = reconciled.completedByIds;
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
if (reconciled.status === 'done' && existing?.status !== 'done') {
data.status = 'done';
data.completedAt = new Date();
} else if (reconciled.status === 'open' && existing?.status === 'done') {
data.status = 'open';
data.completedAt = null;
}
}
}
const row = await this.prisma.activity.update({
@@ -881,7 +1040,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
);
}
if (dto.status === 'done' && existing?.status !== 'done') {
if (data.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +1050,74 @@ export class ActivitiesService {
organizationId: string,
id: string,
actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
select: {
status: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
if (existing.status === 'canceled') {
throw new BadRequestException('فعالیت لغو شده است');
}
const assignees = assigneeIdList(existing);
const allowedForce = canForceClose(actor, existing.createdById);
if (options.force && !allowedForce) {
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
}
if (options.undo) {
if (existing.completionMode !== 'all' || existing.status !== 'open') {
throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد');
}
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
},
include,
});
return this.toDto(row, await this.ctx(organizationId));
}
const personal =
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
if (!personal) {
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
if (existing.status !== 'done') await this.fireDone(organizationId, row);
return this.toDto(row, await this.ctx(organizationId));
}
if (!assignees.includes(actor.userId)) {
throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند');
}
const reconciled = reconcileAllShares(assignees, [
...existing.completedByIds,
actor.userId,
]);
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include,
});
if (existing?.status !== 'done') {
if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -946,6 +1161,18 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing || !canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
}
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true },
@@ -966,6 +1193,19 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
// پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator
if (!gate || !canCollaborateActivity(actor, gate)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید');
}
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
@@ -1060,10 +1300,27 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
const canMutate = !!gate && canMutateActivity(actor, gate);
const canRemoveOwn =
!!gate &&
canCollaborateActivity(actor, gate) &&
row.uploadedById === actor.userId;
if (!canMutate && !canRemoveOwn) {
throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید');
}
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
@@ -1108,25 +1365,18 @@ export class ActivitiesService {
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim();
const facetPriority = facets?.priority;
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetPriority ? [{ priority: facetPriority }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee
@@ -1418,6 +1668,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [],
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import {
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green';
}
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { activityPriorities: true },
});
return parseActivityPrioritySettings(row?.activityPriorities);
}
async updatePriorities(
organizationId: string,
items: Array<{ key: ActivityPriority; label: string; color: string }>,
): Promise<ActivityPrioritySetting[]> {
const byKey = new Map(items.map((i) => [i.key, i]));
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
}
const hex = /^#[0-9a-fA-F]{6}$/;
const stored: Record<string, { label: string; color: string }> = {};
for (const key of ACTIVITY_PRIORITY_KEYS) {
const item = byKey.get(key)!;
const label = item.label.trim().slice(0, 40);
const color = item.color.trim();
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
// فقط تفاوت با پیش‌فرض را نگه می‌داریم تا JSON جمع‌وجور بماند
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
stored[key] = { label, color };
}
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { activityPriorities: stored as Prisma.InputJsonValue },
});
return parseActivityPrioritySettings(stored);
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+46
View File
@@ -9,11 +9,13 @@ import {
IsObject,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -91,6 +93,11 @@ export class ListActivitiesQueryDto {
@IsString()
typeDefId?: string;
/** اولویت فعالیت (کلید ثابت؛ برچسب در تنظیمات سازمان) */
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
@@ -384,6 +391,14 @@ export class CreateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
export class UpdateActivityDto {
@@ -484,4 +499,35 @@ export class UpdateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
export class ActivityPriorityItemDto {
@IsIn(PRIORITY_KEYS)
key!: (typeof PRIORITY_KEYS)[number];
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/)
color!: string;
}
export class UpdateActivityPrioritiesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ActivityPriorityItemDto)
items!: ActivityPriorityItemDto[];
}
+11 -3
View File
@@ -19,6 +19,10 @@ import type {
StorageUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
},
});
const role = membership?.role ?? user.role;
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: [];
const orgName = membership?.organization.name;
const sub =
subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: []) as AuthUserDto['permissions'],
permissions: permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}),
});
}
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({
where: {
teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
},
select: { userId: true },
});
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules,
isPermanentBillingPlan,
isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
}
defaultModulesForNewOrg(): OrgModuleKey[] {
return defaultEnabledModules();
return modulesForProductPlan('core');
}
/** سطح امکانات را روی همهٔ محیط‌های مالک می‌گذارد و سقف هسته/تیم/سازمان را هم تنظیم می‌کند. */
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
const modules = modulesForProductPlan(code);
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
for (const id of orgIds) {
await this.setOrgModules(id, modules);
}
const limits = PRODUCT_PLAN_LIMITS[code];
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
const account = await this.getByOwnerUserId(owner.userId);
if (account) {
await this.prisma.billingAccount.update({
where: { id: account.id },
data: {
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
...(limits.maxOrganizations != null
? { maxOrganizations: limits.maxOrganizations }
: {}),
},
});
}
}
return modules;
}
async countExpiringSoon() {
+138 -109
View File
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
return out;
}
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
if (!facets.length) return null;
return facets.length === 1 ? facets[0]! : { OR: facets };
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
const facets: Prisma.PersonWhereInput[] = [];
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
const ids = ownerIdList(rule, ctx);
if (ids.length) {
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
}
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) {
and.push({
facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
facets.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
return unionFacets(facets);
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
@@ -135,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
return parts.length === 1 ? parts[0]! : { OR: parts };
}
/** اعضای گروه‌هایی که کاربر مسئولشان است */
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
@@ -149,6 +170,8 @@ export function customersPersonWhere(
});
if (w) parts.push(w);
}
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts);
}
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
// حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
const facets: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
if (ids.length) {
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
}
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee)
if (rule.sources?.length) {
and.push({
facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts);
}
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
const facets: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
if (ids.length) {
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
facets.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
const facets: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
if (ids.length) {
facets.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
facets.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -266,32 +285,33 @@ export function activitiesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
const facets: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
if (ids.length) {
facets.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
facets.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
facets.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -310,34 +330,35 @@ export function notesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
const facets: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
if (ids.length) {
facets.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
facets.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return {
AND: [personalGate, orParts(parts)],
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
const facets: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
if (ids.length) {
facets.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
}
if (rule.sources?.length) {
and.push({
facets.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
const facets: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
if (ids.length) {
facets.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
facets.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
facets.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
const facets: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
if (ids.length) {
facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
facets.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
const w = unionFacets(facets);
if (w) parts.push(w);
}
return orParts(parts);
}
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
} else if (
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true;
}
}
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere,
} from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere(
actor: ScopedActor,
actor: DeepAccessActor,
scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope);
if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
}
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
const deep = deepOwnerIds(actor, 'activities');
const ids =
deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return {
OR: [
{ assigneeId: { in: ids } },
+1
View File
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return {
year: today.jy,
month: today.jm,
monthName: jalaliMonthName(today.jm),
today: today.jd,
+8 -3
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
...and,
],
};
const [rows, total] = await this.prisma.$transaction([
@@ -165,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
assignees: [],
}));
}
+2
View File
@@ -3,6 +3,7 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule,
BehaviorModule,
CustomerGroupsModule,
NotificationsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1)
@MaxLength(500)
text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
}
export class BulkDeletePeopleDto {
+2 -1
View File
@@ -139,6 +139,7 @@ export class PeopleController {
}
@Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user);
}
@@ -171,7 +172,7 @@ export class PeopleController {
@Param('id') id: string,
@Body() dto: BirthdaySmsDto,
) {
return this.peopleService.sendBirthdaySms(user, id, dto.text);
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
}
@Get(':id')
+100 -11
View File
@@ -22,6 +22,7 @@ import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -48,6 +49,7 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
@@ -157,6 +159,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {}
private personListWhere(
@@ -216,13 +219,27 @@ export class PeopleService implements OnModuleInit {
}
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
// گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
const facets: Prisma.PersonWhereInput[] = [];
if (groupIds.length) {
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
}
if (moods.length) {
facets.push({ mood: { in: moods } });
}
if (sources.length) {
facets.push({ importSource: { in: sources } });
}
if (facets.length === 1) and.push(facets[0]!);
else if (facets.length > 1) and.push({ OR: facets });
return {
organizationId: actor.organizationId,
...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}),
...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
...and,
],
};
}
@@ -345,6 +362,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0,
companyCount: 0,
assignees: [],
}));
}
@@ -680,8 +698,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null },
...customersPersonWhere(actor),
},
select: {
id: true,
@@ -728,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();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId },
where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: {
id: true,
firstName: true,
@@ -744,11 +766,29 @@ export class PeopleService implements OnModuleInit {
ownerId: true,
birthDate: true,
company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
},
});
if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) {
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.');
const assigneeIds = new Set(
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
);
const canSend =
canViewAllRecords(actor.role) ||
person.ownerId === actor.userId ||
assigneeIds.has(actor.userId);
if (!canSend) {
throw new ForbiddenException(
'فقط مالک مشتری یا مسئول گروه او می‌تواند پیامک تبریک بفرستد.',
);
}
if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -770,6 +810,32 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
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, {
name: fullName,
phone: mobile,
@@ -789,6 +855,7 @@ export class PeopleService implements OnModuleInit {
mobile,
body: message,
source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
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 };
}
@@ -8,7 +8,14 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
class UpdateCustomerGroupDto {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
@Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
},
});
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: { name: string; color?: CustomerGroupColor },
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
name,
color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
},
});
} catch (e) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> {
await this.require(organizationId, id);
const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try {
await this.prisma.customerGroup.update({
where: { id },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
});
} catch (e) {
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null;
}
private toDto(r: {
id: string;
name: string;
color: string;
position: number;
tahesabGid: string | null;
parentId: string | null;
parent?: { name: string } | null;
_count: { people: number; companies: number };
assignees: Array<{ id: string; fullName: string }>;
}): CustomerGroupDto {
return {
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
};
}
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: unique },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId },
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import {
assertFactorCode,
buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index');
}
function testMoshtariProfile() {
const row = {
CodeMelli: '00112312311',
Address: 'خیابان ولیعصر',
City: 'تهران',
Rasteh: 2,
BDate: null,
};
assert(pickNationalId(row) === '00112312311', 'national id');
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
assert(pickCity(row) === 'تهران', 'city');
assert(pickRasteh(row) === 2, 'rasteh');
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
const patch = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: { keep: true },
});
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
const fields = patch.customFields as Record<string, unknown>;
assert(fields.nationalId === '00112312311', 'cf national');
assert(fields.city === 'تهران', 'cf city');
assert(fields.rasteh === 2, 'cf rasteh');
assert(fields.keep === true, 'cf merge');
const emptyKeep = tahesabProfilePatch(
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
{
birthDate: new Date('2000-01-01'),
addresses: ['قدیمی'],
customFields: { nationalId: '111' },
},
);
assert(emptyKeep.addresses == null, 'empty does not wipe address');
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
assert(emptyKeep.birthDate == null, 'existing birth kept');
}
function main() {
testCryptoRoundtrip();
testFactorCode();
testMobile();
testMandehNormalize();
testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK');
}
+6
View File
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number;
}
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto {
@IsString()
connectionId!: string;
@@ -0,0 +1,113 @@
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../../common/excel';
export function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function trimStr(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === '-1') return null;
return s;
}
/** کد ملی از پاسخ لیست مشتری ته‌حساب */
export function pickNationalId(row: Record<string, unknown>): string | null {
return (
trimStr(row.CodeMelli) ??
trimStr(row.CodeMeli) ??
trimStr(row.codeMeli) ??
trimStr(row.NationalId) ??
null
);
}
export function pickAddress(row: Record<string, unknown>): string | null {
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
}
export function pickCity(row: Record<string, unknown>): string | null {
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
}
/** رسته حساب در ته‌حساب (عدد یا رشته) */
export function pickRasteh(row: Record<string, unknown>): string | number | null {
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
if (raw == null || raw === '') return null;
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (!s || s === '-1') return null;
const n = Number(s);
return Number.isFinite(n) && String(n) === s ? n : s;
}
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return {};
}
function asAddressList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => (typeof a === 'string' ? a.trim() : ''))
.filter(Boolean);
}
/**
* فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند.
* مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند.
*/
export function tahesabProfilePatch(
row: Record<string, unknown>,
existing: {
birthDate: Date | null;
addresses: unknown;
customFields: unknown;
},
): Prisma.PersonUpdateInput {
const data: Prisma.PersonUpdateInput = {};
const birthDate = pickBirthDate(row);
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
const address = pickAddress(row);
const city = pickCity(row);
if (address) {
const line = city ? `${address}، ${city}` : address;
data.addresses = [line] as Prisma.InputJsonValue;
} else if (city && asAddressList(existing.addresses).length === 0) {
data.addresses = [city] as Prisma.InputJsonValue;
}
const fields = asCustomFieldRecord(existing.customFields);
let fieldsChanged = false;
const nationalId = pickNationalId(row);
if (nationalId) {
fields.nationalId = nationalId;
fieldsChanged = true;
}
if (city) {
fields.city = city;
fieldsChanged = true;
}
if (address) {
fields.address = address;
fieldsChanged = true;
}
const rasteh = pickRasteh(row);
if (rasteh != null) {
fields.rasteh = rasteh;
fieldsChanged = true;
}
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
return data;
}
@@ -26,8 +26,9 @@ import {
SearchTahesabDto,
SyncGroupsPageDto,
} from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
}
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim());
@@ -438,6 +428,77 @@ export class TahesabPersonLinkService {
};
}
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi(
organizationId,
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
});
const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const results = mapped.map((row) => {
const linked = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return {
...row,
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
: null,
};
});
const duplicates = await this.duplicateOwners(
organizationId,
dto.connectionId,
linked.filter((row) => !row.alreadyLinked),
);
const results = linked.map((row) => {
if (row.alreadyLinked || row.moshtariCode == null) return row;
const dup = duplicates.get(row.moshtariCode);
if (!dup) return row;
return {
...row,
duplicateOfCode: dup.moshtariCode,
duplicatePersonId: dup.personId,
duplicatePersonName: dup.personName,
};
});
await this.prisma.accountingSyncJob.create({
data: {
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
const sourceRef = String(dto.moshtariCode);
let personId = dto.personId;
let preserveExistingLink = false;
if (personId) {
const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName;
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing),
...tahesabProfilePatch(row, existing),
},
});
} else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length
? await this.prisma.person.findMany({
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode;
if (linkedCode != null && linkedCode !== dto.moshtariCode) {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId: byMobile.id, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
return {
personId: byMobile.id,
link: this.linkDto(existing),
duplicateOf: linkedCode,
};
}
const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim();
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
: {}),
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
sourceRef,
...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
},
});
personId = byMobile.id;
} else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({
data: {
organizationId,
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab',
sourceRef,
birthDate: pickBirthDate(row),
birthDate: (profile.birthDate as Date | undefined) ?? null,
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
},
});
personId = created.id;
}
}
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({
where: {
personId_connectionId: {
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) };
}
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
/** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer(
organizationId: string,
connectionId: string,
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(row, existing),
},
});
await this.applyTahesabGroup(organizationId, connectionId, personId, row);
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
let emptyStreak = 0;
let imported = 0;
let updated = 0;
let duplicate = 0;
let failed = 0;
const errors: string[] = [];
const maxPages = 300;
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
);
updated += 1;
} else {
await this.importFromTahesab(
const created = await this.importFromTahesab(
organizationId,
{ connectionId, moshtariCode: code },
{ api, row },
{ skipJob: true },
);
imported += 1;
if (created.duplicateOf) duplicate += 1;
else imported += 1;
}
} catch (err) {
failed += 1;
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
}
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`;
const summary = [
`${imported} وارد شد`,
`${updated} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
const error = errors.length ? errors.join('؛ ') : null;
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId,
kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue,
result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: summary,
customerSyncLastError: error,
},
});
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return {
imported,
updated,
duplicate,
failed,
summary,
error: errors.length ? errors.join('؛ ') : null,
error,
};
}
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
try {
const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
const row = rowsByCode.get(moshtariCode);
if (row) {
await this.refreshLinkedCustomer(
organizationId,
dto.connectionId,
linkedPersonId,
moshtariCode,
row,
);
}
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({
moshtariCode,
ok: true,
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
);
if (r.duplicateOf) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: r.personId,
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
});
continue;
}
items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) {
const message =
@@ -37,6 +37,7 @@ import {
SearchTahesabDto,
SearchTahesabProductsDto,
SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto,
UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto);
}
/** همهٔ مشتریان ته‌حساب: تازه‌ها وارد، لینک‌شده‌ها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) به‌روز */
@Post('sync/customers-now')
@Roles('owner', 'admin')
syncCustomersNow(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAllCustomersDto,
) {
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
}
@Get('sync/unlinked-people')
@Roles('owner', 'admin')
unlinkedPeople(
+21 -17
View File
@@ -176,22 +176,26 @@ export class LeadsService {
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
if (createdRange) and.push(createdRange);
return {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
...(and.length ? { AND: and } : {}),
AND: [
{
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
},
...(opts.unassigned ? [{ assigneeId: null }] : []),
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
...and,
],
};
}
@@ -1185,7 +1189,7 @@ export class LeadsService {
newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }),
this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length
? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } },
@@ -1,4 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
@@ -70,6 +71,16 @@ export class MembershipsService {
}));
}
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
async assertSoleOwner(organizationId: string, userId: string) {
const others = await this.countActiveOwners(organizationId, userId);
if (others > 0) {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
}
async createMembership(data: {
userId: string;
organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number;
isActive?: boolean;
}) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({
data: {
userId: data.userId,
@@ -1,9 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { OutboundSmsLogService } from './outbound-sms-log.service';
import { SmsTemplatesController } from './sms-templates.controller';
import { SmsTemplatesService } from './sms-templates.service';
@Global()
@Module({
providers: [OutboundSmsLogService],
exports: [OutboundSmsLogService],
controllers: [SmsTemplatesController],
providers: [OutboundSmsLogService, SmsTemplatesService],
exports: [OutboundSmsLogService, SmsTemplatesService],
})
export class OutboundSmsModule {}
@@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
import { SmsTemplatesService } from './sms-templates.service';
@Controller('sms-templates')
@UseGuards(JwtAuthGuard)
export class SmsTemplatesController {
constructor(private readonly templates: SmsTemplatesService) {}
@Get()
list(@CurrentUser() user: Actor) {
return this.templates.list(user.organizationId);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateSmsTemplateDto) {
return this.templates.create(user.organizationId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateSmsTemplateDto,
) {
return this.templates.update(user.organizationId, id, dto);
}
@Delete(':id')
remove(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.templates.remove(user.organizationId, id);
}
}
@@ -0,0 +1,28 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { SMS_TEMPLATE_BODY_MAX, SMS_TEMPLATE_NAME_MAX } from '@mento/shared';
export class CreateSmsTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name!: string;
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body!: string;
}
export class UpdateSmsTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(SMS_TEMPLATE_NAME_MAX)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(SMS_TEMPLATE_BODY_MAX)
body?: string;
}
@@ -0,0 +1,92 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Prisma } from '@prisma/client';
import {
parseSmsTemplates,
SMS_TEMPLATE_MAX,
type SmsTemplateDto,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateSmsTemplateDto,
UpdateSmsTemplateDto,
} from './sms-templates.dto';
@Injectable()
export class SmsTemplatesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<SmsTemplateDto[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { smsTemplates: true },
});
return parseSmsTemplates(row?.smsTemplates);
}
async create(
organizationId: string,
dto: CreateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (current.length >= SMS_TEMPLATE_MAX) {
throw new BadRequestException(
`حداکثر ${SMS_TEMPLATE_MAX} پیامک آماده می‌توانید داشته باشید.`,
);
}
const next: SmsTemplateDto[] = [
...current,
{
id: randomUUID(),
name: dto.name.trim(),
body: dto.body.trim(),
},
];
return this.persist(organizationId, next);
}
async update(
organizationId: string,
id: string,
dto: UpdateSmsTemplateDto,
): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
const idx = current.findIndex((item) => item.id === id);
if (idx < 0) throw new NotFoundException('پیامک آماده پیدا نشد.');
const prev = current[idx]!;
const next = [...current];
next[idx] = {
id: prev.id,
name: dto.name !== undefined ? dto.name.trim() : prev.name,
body: dto.body !== undefined ? dto.body.trim() : prev.body,
};
return this.persist(organizationId, next);
}
async remove(organizationId: string, id: string): Promise<SmsTemplateDto[]> {
const current = await this.list(organizationId);
if (!current.some((item) => item.id === id)) {
throw new NotFoundException('پیامک آماده پیدا نشد.');
}
return this.persist(
organizationId,
current.filter((item) => item.id !== id),
);
}
private async persist(
organizationId: string,
items: SmsTemplateDto[],
): Promise<SmsTemplateDto[]> {
const stored = parseSmsTemplates(items);
await this.prisma.organization.update({
where: { id: organizationId },
data: { smsTemplates: stored as unknown as Prisma.InputJsonValue },
});
return stored;
}
}
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {}
@Get()
list(@CurrentUser() user: { organizationId: string }) {
return this.pipelinesService.list(user.organizationId);
list(
@CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
}
@Get('board')
+154 -9
View File
@@ -4,6 +4,10 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto,
MergePipelineResultDto,
PipelineBoardDto,
@@ -102,24 +106,55 @@ export class PipelinesService {
return this.toPipelineDto(created);
}
async list(organizationId: string): Promise<PipelineDto[]> {
async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
const organizationId =
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({
where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
const counts = await this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId },
_count: { _all: true },
});
const lens = actor?.priorLens;
const lensActive = forLens && (lens === 'current' || lens === 'archive');
const access = actor ? dealAccessWhere(actor) : {};
const prior = lensActive
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
: {};
const [counts, lensCounts] = await Promise.all([
this.prisma.deal.groupBy({
by: ['pipelineId'],
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
_count: { _all: true },
}),
lensActive
? this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId, AND: [access, prior] },
_count: { _all: true },
})
: Promise.resolve([]),
]);
const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]),
);
return rows.map((row) => ({
const lensCountByPipeline = new Map(
lensCounts.map((row) => [row.pipelineId, row._count._all]),
);
const visible = lensActive
? rows.filter((row) => {
const inLens = lensCountByPipeline.get(row.id) ?? 0;
const total = dealCountByPipeline.get(row.id) ?? 0;
if (lens === 'archive') return inLens > 0;
return inLens > 0 || total === 0;
})
: rows;
return visible.map((row) => ({
...this.toPipelineDto(row),
dealCount: dealCountByPipeline.get(row.id) ?? 0,
dealCount: lensActive
? (lensCountByPipeline.get(row.id) ?? 0)
: (dealCountByPipeline.get(row.id) ?? 0),
}));
}
@@ -343,12 +378,23 @@ export class PipelinesService {
});
const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) {
const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal));
if (list) {
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
}
return {
@@ -566,6 +612,105 @@ export class PipelinesService {
};
}
private async loadDealActivityHints(
organizationId: string,
dealIds: string[],
): Promise<
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
> {
const hints = new Map<
string,
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
>();
if (!dealIds.length) return hints;
const rows = await this.prisma.activity.findMany({
where: {
organizationId,
dealId: { in: dealIds },
status: { in: ['open', 'done'] },
},
select: {
id: true,
dealId: true,
title: true,
type: true,
status: true,
dueAt: true,
completedAt: true,
},
});
type Row = {
id: string;
title: string;
type: string;
status: 'open' | 'done';
dueAt: Date | null;
completedAt: Date | null;
};
const byDeal = new Map<string, Row[]>();
for (const row of rows) {
if (!row.dealId) continue;
const list = byDeal.get(row.dealId) ?? [];
list.push({
id: row.id,
title: row.title,
type: row.type,
status: row.status === 'done' ? 'done' : 'open',
dueAt: row.dueAt,
completedAt: row.completedAt,
});
byDeal.set(row.dealId, list);
}
const now = Date.now();
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
if (item.status === 'done') return 'done';
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
return 'upcoming';
};
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
for (const dealId of dealIds) {
const list = byDeal.get(dealId) ?? [];
const summaries = list
.map((item) => {
const cue = cueOf(item);
return {
id: item.id,
title: item.title,
type: item.type as ActivityType,
status: item.status as ActivityStatus,
dueAt: item.dueAt?.toISOString() ?? null,
cue,
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
_done: item.completedAt?.getTime() ?? 0,
};
})
.sort((a, b) => {
const byRank = rank(a.cue) - rank(b.cue);
if (byRank) return byRank;
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
return a._due - b._due;
})
.map(({ _due: _d, _done: _c, ...summary }) => summary);
const open = list.filter((item) => item.status === 'open');
let cue: DealActivityCue = 'none';
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
cue = 'overdue';
} else if (open.length) {
cue = 'upcoming';
} else if (list.some((item) => item.status === 'done')) {
cue = 'done';
}
hints.set(dealId, { cue, summaries });
}
return hints;
}
toDealDto(row: DealRow): DealDto {
return {
id: row.id,
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared';
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null;
}
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto,
SetUserCapDto,
CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers);
}
@Post('organizations/:id/product-plan')
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
return this.platform.applyProductPlan(id, dto.planCode);
}
@Patch('organizations/:id/modules')
updateOrganizationModules(
@Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
ProductPlanCode,
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId);
}
async applyProductPlan(id: string, planCode: ProductPlanCode) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.applyProductPlan(id, planCode);
return this.getOrganization(id);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({
where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
}
if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (role === 'owner' && row.role !== 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners(
row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
@@ -1534,7 +1535,7 @@ export class ReportsService {
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals');
const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId };
const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date();
const { jy, jm } = dateToJalali(now);
+20 -10
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed,
type TreasurySectionKey,
} from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true;
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String)
: [];
const permissions = permissionsForTreasury(
const permissions = permissionsWithTreasuryOverride(
rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride),
);
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || '';
if (
isLookupForDocuments(method, url) &&
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
) {
return true;
}
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
}
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
}
}
function permissionsForTreasury(
rolePermissions: string[],
override: ReturnType<typeof parseUserTreasuryAccess>,
): string[] {
if (!override) return rolePermissions;
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
/** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
function isLookupForDocuments(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
}
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
+141 -1
View File
@@ -13,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2);
}
function reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
function periodKey(date: Date) {
return date.toISOString().slice(0, 10);
}
@@ -62,8 +71,11 @@ export class TreasuryOpsService {
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string;
notes?: string | null;
},
@@ -88,8 +100,11 @@ export class TreasuryOpsService {
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null,
@@ -137,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial';
const status = payrollStatus(row.payable, paid.plus(amount));
await this.prisma.$transaction([
this.prisma.financialMovement.create({
data: {
@@ -158,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor);
}
async updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId },
@@ -495,8 +627,11 @@ export class TreasuryOpsService {
period: string;
baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal;
dueDate: Date;
status: string;
@@ -504,6 +639,7 @@ export class TreasuryOpsService {
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | null;
}>;
@@ -519,8 +655,11 @@ export class TreasuryOpsService {
period: row.period,
baseAmount: money(row.baseAmount),
benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable),
paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left),
@@ -532,6 +671,7 @@ export class TreasuryOpsService {
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '',
})),
};
@@ -129,6 +129,29 @@ export class TreasuryController {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
}
@Post('party-balances/collect')
collectPartyBalance(
@CurrentUser() user: Actor,
@Body()
body: {
kind?: string;
partyKind?: 'person' | 'company' | 'user';
partyId?: string;
amount?: string | number;
accountId?: string;
occurredAt?: string;
},
) {
return this.treasury.collectPartyBalance(user, {
kind: body.kind === 'expense' ? 'expense' : 'income',
partyKind: body.partyKind ?? 'person',
partyId: body.partyId ?? '',
amount: body.amount ?? 0,
accountId: body.accountId ?? '',
occurredAt: body.occurredAt,
});
}
@Get('documents')
documents(
@CurrentUser() user: Actor,
@@ -232,6 +255,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body);
}
@Patch('payroll/:id')
updatePayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
) {
return this.ops.updatePayroll(user, id, body);
}
@Patch('payroll/:id/payments/:movementId')
updatePayrollPayment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('movementId') movementId: string,
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
) {
return this.ops.updatePayrollPayment(user, id, movementId, body);
}
@Post('payroll/:id/pay')
payPayroll(
@CurrentUser() user: Actor,
+284 -8
View File
@@ -360,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
...(from || to
? {
occurredAt: {
@@ -378,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId,
accountId,
voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from },
},
select: { direction: true, amount: true },
@@ -531,6 +533,8 @@ export class TreasuryService {
settleAmount?: string | number | null;
accountId?: string | null;
settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
},
): Promise<FinancialDocumentDto> {
const title = input.title.trim();
@@ -561,6 +565,17 @@ export class TreasuryService {
}
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const settlement = input.settlement === true;
if (settlement) {
if (party.partyKind === 'other') {
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
}
if (!settle.equals(amount) || !settle.greaterThan(0)) {
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
}
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
}
const payExisting = settlement;
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
@@ -607,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({
where: { id: created.id },
include: { category: true, account: true, movements: true },
@@ -728,6 +782,7 @@ export class TreasuryService {
amount: Prisma.Decimal;
settled: Prisma.Decimal;
remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>();
for (const row of rows) {
if (row.partyKind === 'other') continue;
@@ -748,10 +803,12 @@ export class TreasuryService {
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current);
}
@@ -770,6 +827,7 @@ export class TreasuryService {
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
@@ -790,9 +848,122 @@ export class TreasuryService {
amount: money(row.amount),
settledAmount: money(row.settled),
remaining: money(row.remaining),
documentRemaining: money(row.documentRemaining),
}));
}
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
async collectPartyBalance(
actor: Actor,
input: {
kind: 'income' | 'expense';
partyKind: 'person' | 'company' | 'user';
partyId: string;
amount: string | number;
accountId: string;
occurredAt?: string;
},
): Promise<{ applied: string; documentCount: number }> {
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const partyId = input.partyId.trim();
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
}
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
const scope = await this.accountScope(actor);
const docs = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: input.kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.partyKind,
...(input.partyKind === 'person' ? { personId: partyId } : {}),
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
...(input.partyKind === 'user' ? { userId: partyId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const open = docs
.map((doc) => {
const remaining = doc.amount.minus(this.settledOf(doc.movements));
return { doc, remaining };
})
.filter((row) => row.remaining.greaterThan(0));
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
if (!available.greaterThan(0)) {
throw new BadRequestException(
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
);
}
if (amount.greaterThan(available)) {
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
}
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(
actor: Actor,
input: {
@@ -936,16 +1107,51 @@ export class TreasuryService {
});
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date();
await this.prisma.$transaction([
this.prisma.financialMovement.updateMany({
await this.prisma.$transaction(async (tx) => {
const allocations = await tx.financialMovement.findMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
select: { documentId: true },
});
await tx.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now },
}),
this.prisma.financialDocument.update({
});
await tx.financialMovement.updateMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
data: { voidedAt: now },
});
await tx.financialDocument.update({
where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}),
]);
});
const excess = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
notes: `source:${doc.id}`,
voidedAt: null,
status: { in: ['open', 'partial'] },
},
include: { movements: { select: { voidedAt: true } } },
});
for (const child of excess) {
if (child.movements.some((row) => !row.voidedAt)) continue;
await tx.financialDocument.update({
where: { id: child.id },
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
});
}
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
for (const documentId of affected) {
const row = await tx.financialDocument.findUnique({
where: { id: documentId },
include: { movements: { select: { amount: true, voidedAt: true } } },
});
if (!row || row.voidedAt || row.status === 'void') continue;
const settled = this.settledOf(row.movements);
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
}
});
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id },
include: { category: true, account: true, movements: true },
@@ -1150,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 }>) {
return movements
.filter((row) => !row.voidedAt)
@@ -1158,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null },
where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true },
});
return rows.reduce(
@@ -1233,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from);
const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null },
where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true, kind: true, occurredAt: true },
});
let balance = new Prisma.Decimal(0);
+15 -3
View File
@@ -104,8 +104,10 @@ export class UsersService {
orgRoleId: dto.orgRoleId,
role: dto.role,
});
if (resolved.role === 'owner' && actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
if (resolved.role === 'owner') {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
@@ -449,6 +451,10 @@ export class UsersService {
}
}
if (nextRole === 'owner' && target.role !== 'owner') {
await this.memberships.assertSoleOwner(organizationId, target.userId);
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners(
organizationId,
@@ -456,7 +462,7 @@ export class UsersService {
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
);
}
}
@@ -528,6 +534,9 @@ export class UsersService {
}
}
if (isActive && !target.isActive) {
if (target.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, targetId);
}
await this.billing.assertSeatAvailable(organizationId, targetId);
}
await this.prisma.membership.update({
@@ -558,6 +567,9 @@ export class UsersService {
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId);
if (resolved.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, userId);
}
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
+52 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access';
import {
customersCompanyWhere,
customersPersonWhere,
leadsDeepWhere,
resolveSectionAccess,
} from '../src/common/data-access';
import {
noteAccessWhere,
normalizeDataVisibility,
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخش‌ها را ذ
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
});
});
describe('مسئول گروه مشتری روی عمق داده', () => {
const selfAccess = { mode: 'rules' as const, rules: [{ owners: ['self' as const] }] };
it('محدودیت self با OR مسئول گروه برای شخص', () => {
const where = customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('محدودیت self با OR مسئول گروه برای شرکت', () => {
const where = customersCompanyWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('بدون محدودیت عمقی، فیلتر اضافه نمی‌شود', () => {
assert.deepEqual(
customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: { mode: 'all', rules: [] },
teamPeerIds: null,
}),
{},
);
});
});
@@ -126,6 +126,9 @@ function apply() {
<strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header>
<p v-if="isPeople" class="facet-hint">
گروه، منبع و وضعیت با هم جمع می‌شوند؛ هر کدام کافی است (بدون تکرار).
</p>
<div class="panel-body">
<section v-if="hasSort" class="sec">
@@ -264,6 +267,15 @@ function apply() {
border-bottom: 1px solid var(--line);
}
.facet-hint {
margin: 0;
padding: 0.35rem 0.9rem 0.55rem;
font-size: var(--fs-sm);
color: #64748b;
line-height: 1.45;
border-bottom: 1px solid var(--line);
}
.panel-foot {
border-top: 1px solid var(--line);
justify-content: flex-end;
+237 -32
View File
@@ -1,29 +1,54 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import type { CustomerGroupColor, CustomerGroupDto, OrgUserDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import {
CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch,
useCustomerGroups,
} from '@/lib/customer-groups';
import { useAuthStore } from '@/stores/auth';
import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const auth = useAuthStore();
const canManage = computed(
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
);
const { groups, load } = useCustomerGroups();
const users = ref<OrgUserDto[]>([]);
const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal');
const newAssigneeIds = ref<string[]>([]);
const editingId = ref<string | null>(null);
const editName = ref('');
const assigneeDraft = ref<Record<string, string[]>>({});
const listQuery = ref('');
const busy = ref(false);
const error = ref('');
const filteredGroups = computed(() => {
const q = normalizeSearchText(listQuery.value);
if (!q) return groups.value;
return groups.value.filter((g) => {
const assigneeNames = (g.assignees ?? []).map((a) => a.fullName).join(' ');
return (
normalizeSearchText(g.name).includes(q) ||
normalizeSearchText(g.parentName ?? '').includes(q) ||
normalizeSearchText(assigneeNames).includes(q)
);
});
});
function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
return fallback;
}
async function loadUsers() {
try {
const { data } = await api.get<OrgUserDto[]>('/users');
users.value = data.filter((u) => u.isActive !== false);
} catch {
users.value = [];
}
}
function syncAssigneeDraft() {
const next: Record<string, string[]> = {};
for (const g of groups.value) {
next[g.id] = (g.assignees ?? []).map((a) => a.id);
}
assigneeDraft.value = next;
}
async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true;
error.value = '';
try {
await task();
await load(true);
syncAssigneeDraft();
emit('changed');
return true;
} catch (e) {
@@ -53,10 +96,18 @@ async function create() {
const name = newName.value.trim();
if (!name) return;
const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }),
() =>
api.post('/customer-groups', {
name,
color: newColor.value,
assigneeIds: newAssigneeIds.value,
}),
'ساخت گروه ناموفق بود.',
);
if (ok) newName.value = '';
if (ok) {
newName.value = '';
newAssigneeIds.value = [];
}
}
function startEdit(g: CustomerGroupDto) {
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
}
async function onAssigneesChange(g: CustomerGroupDto, ids: string[]) {
assigneeDraft.value = { ...assigneeDraft.value, [g.id]: ids };
const prev = (g.assignees ?? []).map((a) => a.id).sort().join(',');
const next = [...ids].sort().join(',');
if (prev === next) return;
await run(
() => api.patch(`/customer-groups/${g.id}`, { assigneeIds: ids }),
'ذخیره مسئولان گروه ناموفق بود.',
);
}
function assigneeLabel(g: CustomerGroupDto) {
const names = (g.assignees ?? []).map((a) => a.fullName).filter(Boolean);
if (!names.length) return 'بدون مسئول';
if (names.length === 1) return names[0]!;
return `${names[0]} و ${toFaDigits(names.length - 1)} نفر دیگر`;
}
async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
@@ -95,9 +164,12 @@ watch(
if (!open) return;
error.value = '';
editingId.value = null;
void load(true).catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
listQuery.value = '';
void Promise.all([load(true), loadUsers()])
.then(() => syncAssigneeDraft())
.catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
},
{ immediate: true },
);
@@ -107,34 +179,60 @@ watch(
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager">
<p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب
«ته‌حساب» مشخص شده‌اند.
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
مشتریان خود می‌بینند. گروه‌های ته‌حساب با برچسب «ته‌حساب» مشخص شده‌اند.
</p>
<form class="new-row" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
<form v-if="canManage" class="create-panel" @submit.prevent="create">
<header class="create-head">
<strong>گروه جدید</strong>
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
</header>
<div class="new-row">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</div>
<div v-if="users.length" class="assignee-field create-assignees">
<span class="assignees-label">مسئولان (اختیاری)</span>
<OrgUserPersonPick
v-model="newAssigneeIds"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list">
<li v-for="g in groups" :key="g.id" class="row">
<div v-if="canManage || groups.length" class="list-head">
<h3 class="list-title">گروه‌های موجود</h3>
<label v-if="groups.length" class="list-search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجوی گروه یا مسئول…"
/>
</label>
</div>
<ul v-if="filteredGroups.length" class="list">
<li v-for="g in filteredGroups" :key="g.id" class="row">
<div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
@@ -152,9 +250,10 @@ watch(
<span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span>
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
</template>
</div>
<div v-if="editingId !== g.id" class="row-actions">
<div v-if="editingId !== g.id && canManage" class="row-actions">
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
@@ -173,8 +272,20 @@ watch(
<UiIcon name="trash" :size="14" />
</UiButton>
</div>
<div v-if="canManage && users.length" class="assignee-field">
<span class="assignees-label">مسئولان</span>
<OrgUserPersonPick
:model-value="assigneeDraft[g.id] ?? []"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
@update:model-value="(ids) => (assigneeDraft[g.id] = ids)"
@change="(ids) => onAssigneesChange(g, ids)"
/>
</div>
</li>
</ul>
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div>
</UiModal>
@@ -200,6 +311,78 @@ watch(
font-size: 0.85rem;
}
.create-panel {
display: grid;
gap: 0.75rem;
padding: 0.85rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--accent, #0d9488) 28%, #e2e8f0);
border-radius: 12px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, #fff) 0%, #fff 72%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.create-head {
display: grid;
gap: 0.15rem;
}
.create-head strong {
color: var(--accent-deep, #0f766e);
font-size: 0.9rem;
font-weight: 700;
}
.create-head span {
color: #64748b;
font-size: 0.78rem;
}
.list-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.list-title {
margin: 0;
color: #475569;
font-size: 0.8rem;
font-weight: 700;
}
.list-search {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: min(16rem, 100%);
flex: 1 1 12rem;
max-width: 18rem;
height: 2.25rem;
padding: 0 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
color: #94a3b8;
}
.list-search:focus-within {
border-color: #5eead4;
}
.list-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.85rem;
color: #0f172a;
}
.new-row,
.edit {
display: flex;
@@ -240,11 +423,9 @@ watch(
.list {
list-style: none;
margin: 0;
padding: 0;
padding: 0 0 0.25rem;
display: grid;
gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
}
.row {
@@ -282,14 +463,38 @@ watch(
padding: 0.05rem 0.4rem;
}
.count {
.count,
.assignee-summary {
color: #94a3b8;
font-size: 0.78rem;
}
.assignee-summary {
flex: 1 1 100%;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.assignee-field {
flex: 1 1 100%;
display: grid;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed #e2e8f0;
}
.create-assignees {
border-top: 1px dashed color-mix(in srgb, var(--accent, #0d9488) 22%, #e2e8f0);
padding-top: 0.65rem;
}
.assignees-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
}
</style>
+33 -24
View File
@@ -22,7 +22,6 @@ const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[];
pipelines?: IdOption[];
boards?: IdOption[];
@@ -36,7 +35,6 @@ const props = withDefaults(
}>(),
{
section: 'customers',
groups: () => [],
stages: () => [],
pipelines: () => [],
boards: () => [],
@@ -53,9 +51,9 @@ const emit = defineEmits<{
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers');
const isCustomers = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets');
@@ -119,9 +117,8 @@ const boardsLegend = computed(() =>
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! };
if (rules.length === 1) return stripCustomerGroupIds({ ...rules[0]! });
const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>();
const moods = new Set<PersonMood>();
const stageIds = new Set<string>();
@@ -130,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
const typeIds = new Set<string>();
for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id);
@@ -140,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
}
const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds];
@@ -150,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
return out;
}
/** دسترسی گروه فقط از مسئول‌گروه؛ groupIds دیگر در فیلتر مشتریان ذخیره/نمایش نمی‌شود */
function stripCustomerGroupIds(rule: DataAccessRule): DataAccessRule {
if (!isCustomers.value || !rule.groupIds?.length) return rule;
const { groupIds: _drop, ...rest } = rule;
return rest;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean(
rule.owners?.length ||
rule.groupIds?.length ||
(!isCustomers.value && rule.groupIds?.length) ||
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
@@ -184,10 +186,26 @@ const mode = computed({
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
/** پاک‌سازی groupIds قدیمی مشتریان هنگام باز شدن ویرایشگر */
watch(
() => [props.section, props.modelValue] as const,
() => {
if (!isCustomers.value || props.modelValue?.mode !== 'rules') return;
const rules = props.modelValue.rules ?? [];
if (!rules.some((r) => r.groupIds?.length)) return;
const collapsed = collapseRules(rules);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
{ immediate: true },
);
function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (!isCustomers.value && next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
@@ -210,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
@@ -293,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
const pickers = computed((): Picker[] => {
const rule = filter.value;
const items: Picker[] = [];
if (showGroups.value) {
items.push({
key: 'groupIds',
legend: 'گروه‌ها',
mode: 'multiple',
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
options: withSelected(namedOptions(props.groups), rule.groupIds),
});
}
if (showImportSources.value) {
const ready = props.importSourcesReady;
const failed = props.importSourcesFailed;
@@ -417,7 +424,6 @@ const filterSummary = computed(() => {
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
@@ -443,9 +449,12 @@ const filterSummary = computed(() => {
<template v-if="mode === 'rules'">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
فیلترهای پرشده با هم
<strong>جمع می‌شوند</strong>
؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
<template v-if="isCustomers">
اعضای گروه‌هایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه می‌شوند.
</template>
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
@@ -16,7 +16,6 @@ import {
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
@@ -85,7 +84,6 @@ function setSection(key: DataAccessSection, next: SectionDataAccess) {
});
}
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false);
const importSourcesFailed = ref(false);
@@ -142,7 +140,6 @@ async function loadTreasuryAccounts() {
}
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
@@ -362,7 +359,6 @@ const typesForActive = computed(() => {
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
+9 -2
View File
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string;
clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(),
{
mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false,
clearable: true,
icon: undefined,
displayText: '',
startView: 'days',
},
);
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
});
const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return '';
@@ -353,7 +360,7 @@ function place() {
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer')) {
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
@@ -368,7 +375,7 @@ function onWindowChange() {
async function show() {
if (props.disabled) return;
syncDraftFrom(parsed.value);
calView.value = 'days';
calView.value = props.startView;
clockFace.value = 'hour';
syncViewport();
liftAboveOverlays();
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean;
dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(),
{
required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2,
asNumber: false,
dir: 'ltr',
addonAfter: '',
},
);
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled"
:readonly="readonly"
:value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus"
@blur="onBlur"
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import type { OrgUserDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
const props = withDefaults(
defineProps<{
modelValue: string[];
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
disabled?: boolean;
emptyLabel?: string;
}>(),
{
disabled: false,
emptyLabel: 'انتخاب مسئول',
},
);
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const searchInput = ref<HTMLInputElement | null>(null);
const selected = computed(() =>
props.modelValue
.map((id) => props.users.find((u) => u.id === id))
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
);
const label = computed(() => {
if (!selected.value.length) return props.emptyLabel;
if (selected.value.length === 1) return selected.value[0]!.fullName;
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
});
const filtered = computed(() => {
const q = normalizeSearchText(query.value);
if (!q) return props.users;
return props.users.filter(
(u) =>
normalizeSearchText(u.fullName).includes(q) ||
normalizeSearchText(u.orgRoleName ?? '').includes(q),
);
});
function toggleMenu() {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
void nextTick(() => searchInput.value?.focus());
}
}
function close() {
open.value = false;
}
function setIds(next: string[]) {
emit('update:modelValue', next);
emit('change', next);
}
function toggle(userId: string) {
if (props.disabled) return;
const set = new Set(props.modelValue);
if (set.has(userId)) set.delete(userId);
else set.add(userId);
setIds([...set]);
}
function remove(userId: string) {
if (props.disabled) return;
if (!props.modelValue.includes(userId)) return;
setIds(props.modelValue.filter((id) => id !== userId));
}
watch(open, (isOpen) => {
if (!isOpen) query.value = '';
});
defineExpose({ close });
</script>
<template>
<div class="wrap" @click.stop>
<button
class="picker person-pick"
type="button"
:disabled="disabled"
@click="toggleMenu"
>
<div class="assignee-avatars">
<UserAvatar
v-for="user in selected.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
</div>
<span :class="{ ghost: !selected.length }">{{ label }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<div v-if="selected.length" class="chips">
<span v-for="user in selected" :key="user.id" class="chip">
{{ user.fullName }}
<button
type="button"
class="chip-x"
:disabled="disabled"
:aria-label="`حذف ${user.fullName}`"
@click="remove(user.id)"
>
×
</button>
</span>
</div>
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="searchInput"
v-model="query"
type="search"
placeholder="جستجوی نام یا نقش…"
@keydown.esc="close"
/>
</label>
<ul class="person-list">
<li v-for="user in filtered" :key="user.id">
<button
type="button"
class="multi-option"
:class="{ on: modelValue.includes(user.id) }"
@click="toggle(user.id)"
>
<span class="check" :class="{ on: modelValue.includes(user.id) }">
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<span class="role-name">{{ user.fullName }}</span>
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
</button>
</li>
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
</ul>
</div>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 12rem;
}
.picker {
width: 100%;
min-height: var(--control-h, 36px);
height: var(--control-h, 36px);
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.picker:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.picker .ghost {
color: #94a3b8;
font-weight: 500;
}
.picker :deep(.ico:last-child) {
margin-inline-start: auto;
color: #94a3b8;
transform: rotate(90deg);
}
.assignee-avatars {
display: flex;
align-items: center;
}
.assignee-avatars :deep(.avatar),
.assignee-avatars > * {
margin-inline-end: -0.35rem;
box-shadow: 0 0 0 2px #fff;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #99f6e4;
color: #0f766e;
font-size: 0.78rem;
font-weight: 600;
}
.chip-x {
appearance: none;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.2rem;
border-radius: 999px;
}
.chip-x:hover:not(:disabled) {
background: #ccfbf1;
}
.chip-x:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: absolute;
inset-inline: 0;
top: calc(100% + 0.25rem);
z-index: 40;
margin: 0;
background: #fff;
border-radius: var(--radius, 10px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
}
.person-menu {
max-height: none;
overflow: visible;
display: grid;
gap: 0.35rem;
padding: 0.45rem;
border: 1px solid #e2e8f0;
}
.person-search {
display: flex;
align-items: center;
gap: 0.45rem;
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
color: #94a3b8;
}
.person-search:focus-within {
border-color: #5eead4;
}
.person-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: #0f172a;
}
.person-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 15rem;
overflow-y: auto;
}
.multi-menu .multi-option {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: var(--radius, 10px);
font: inherit;
text-align: start;
cursor: pointer;
}
.multi-menu .multi-option:hover {
background: #f8fafc;
}
.multi-menu .multi-option.on {
background: #f0fdfa;
}
.multi-menu .check {
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.multi-menu .check.on {
background: #14b8a6;
border-color: #14b8a6;
}
.multi-menu .role-name {
min-width: 0;
}
.multi-menu .role-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.menu-empty {
padding: 0.6rem;
color: #94a3b8;
font-size: 0.82rem;
}
</style>
+292 -8
View File
@@ -1,8 +1,17 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
import type {
OutboundSmsHistoryResponse,
OutboundSmsLogDto,
SmsTemplateDto,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_FIELDS,
SMS_TEMPLATE_MAX,
SMS_TEMPLATE_NAME_MAX,
countSmsParts,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
@@ -40,13 +49,25 @@ const busy = ref(false);
const error = ref('');
const done = ref('');
const fieldsOpen = ref(false);
const templatesOpen = ref(false);
const savingTemplate = ref(false);
const templateName = ref('');
const bodyEl = ref<HTMLTextAreaElement | null>(null);
const history = ref<OutboundSmsLogDto[]>([]);
const historyLoading = ref(false);
const templates = ref<SmsTemplateDto[]>([]);
const templatesLoading = ref(false);
const preview = computed(() => fill(text.value));
const count = computed(() => text.value.length);
const parts = computed(() => countSmsParts(text.value));
const canSaveTemplate = computed(
() =>
text.value.trim().length >= 2 &&
templateName.value.trim().length >= 1 &&
templates.value.length < SMS_TEMPLATE_MAX &&
!savingTemplate.value,
);
watch(
() => props.open,
@@ -56,8 +77,11 @@ watch(
error.value = '';
done.value = '';
fieldsOpen.value = false;
templatesOpen.value = false;
templateName.value = '';
busy.value = false;
void loadHistory();
void loadTemplates();
},
);
@@ -77,6 +101,18 @@ async function loadHistory() {
}
}
async function loadTemplates() {
templatesLoading.value = true;
try {
const { data } = await api.get<SmsTemplateDto[]>('/sms-templates');
templates.value = Array.isArray(data) ? data : [];
} catch {
templates.value = [];
} finally {
templatesLoading.value = false;
}
}
function fill(source: string) {
const map: Record<string, string> = {
نام: props.name,
@@ -111,7 +147,49 @@ function insertField(token: string) {
el.setSelectionRange(caret, caret);
});
}
fieldsOpen.value = false;
}
function toggleFields() {
fieldsOpen.value = !fieldsOpen.value;
}
function toggleTemplates() {
templatesOpen.value = !templatesOpen.value;
if (templatesOpen.value) void loadTemplates();
}
function applyTemplate(item: SmsTemplateDto) {
text.value = item.body;
templatesOpen.value = false;
error.value = '';
}
async function saveTemplate() {
const name = templateName.value.trim().slice(0, SMS_TEMPLATE_NAME_MAX);
const body = text.value.trim();
if (!canSaveTemplate.value || !name || body.length < 2) return;
savingTemplate.value = true;
error.value = '';
try {
const { data } = await api.post<SmsTemplateDto[]>('/sms-templates', { name, body });
templates.value = Array.isArray(data) ? data : [];
templateName.value = '';
} catch (e) {
error.value = apiMessage(e, 'ذخیره پیامک آماده ناموفق بود.');
} finally {
savingTemplate.value = false;
}
}
async function removeTemplate(item: SmsTemplateDto) {
if (!confirm(`پیامک آماده «${item.name}» حذف شود؟`)) return;
error.value = '';
try {
const { data } = await api.delete<SmsTemplateDto[]>(`/sms-templates/${item.id}`);
templates.value = Array.isArray(data) ? data : [];
} catch (e) {
error.value = apiMessage(e, 'حذف پیامک آماده ناموفق بود.');
}
}
function apiMessage(e: unknown, fallback: string) {
@@ -122,17 +200,27 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
async function send() {
async function send(force = false) {
if (!props.endpoint || busy.value || done.value) return;
busy.value = true;
error.value = '';
try {
await api.post(props.endpoint, { text: text.value });
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
done.value = 'پیامک ارسال شد.';
emit('sent');
void loadHistory();
} catch (e) {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
if (axios.isAxiosError(e) && e.response?.status === 409) {
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
if (confirm(msg)) {
busy.value = false;
await send(true);
return;
}
error.value = msg;
} else {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
}
} finally {
busy.value = false;
}
@@ -152,7 +240,19 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
</p>
<div class="tools">
<button type="button" class="pill" @click="fieldsOpen = !fieldsOpen">فیلدهای پویا</button>
<div class="tool-row">
<button type="button" class="pill" :class="{ on: fieldsOpen }" @click="toggleFields">
فیلدهای پویا
</button>
<button
type="button"
class="pill"
:class="{ on: templatesOpen }"
@click="toggleTemplates"
>
پیامک‌های آماده
</button>
</div>
<div v-if="fieldsOpen" class="fields">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
@@ -164,6 +264,55 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
<span>{{ item.label }}</span>
</button>
</div>
<div v-if="templatesOpen" class="templates">
<p v-if="templatesLoading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!templates.length" class="muted empty">
هنوز پیامک آماده‌ای نیست. متن را بنویسید و ذخیره کنید.
</p>
<ul v-else class="template-list">
<li v-for="item in templates" :key="item.id">
<button type="button" class="pick" @click="applyTemplate(item)">
<strong>{{ item.name }}</strong>
<span>{{ item.body }}</span>
</button>
<button
type="button"
class="del"
:aria-label="`حذف ${item.name}`"
@click="removeTemplate(item)"
>
حذف
</button>
</li>
</ul>
<div class="tpl-fields" role="group" aria-label="فیلدهای پویا برای متن آماده">
<span class="tpl-fields-label">درج فیلد در متن:</span>
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="`tpl-${item.token}`"
type="button"
:title="item.label"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
</button>
</div>
<form class="save-row" @submit.prevent="saveTemplate">
<input
v-model="templateName"
type="text"
:maxlength="SMS_TEMPLATE_NAME_MAX"
placeholder="نام پیامک آماده"
autocomplete="off"
/>
<button type="submit" :disabled="!canSaveTemplate">
{{ savingTemplate ? '…' : 'ذخیره متن فعلی' }}
</button>
</form>
<p v-if="templates.length >= SMS_TEMPLATE_MAX" class="muted">
سقف {{ toFaDigits(SMS_TEMPLATE_MAX) }} قالب پر شده است.
</p>
</div>
</div>
<textarea ref="bodyEl" v-model="text" rows="4" maxlength="500" placeholder="سلام {نام} عزیز" />
@@ -238,6 +387,12 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
margin-bottom: 0.45rem;
}
.tool-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pill,
.fields button {
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
@@ -253,7 +408,14 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
font-size: 0.8rem;
}
.fields {
.pill.on {
background: var(--accent-deep);
color: #fff;
border-color: var(--accent-deep);
}
.fields,
.templates {
position: absolute;
z-index: 2;
top: calc(100% + 0.3rem);
@@ -283,6 +445,128 @@ function statusLabel(status: OutboundSmsLogDto['status']) {
color: var(--accent-deep);
}
.template-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
max-height: 11rem;
overflow: auto;
}
.template-list li {
display: flex;
align-items: stretch;
gap: 0.3rem;
}
.template-list .pick {
flex: 1;
min-width: 0;
display: grid;
gap: 0.15rem;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.5rem;
background: #f8fafc;
text-align: start;
font: inherit;
cursor: pointer;
color: #0f172a;
}
.template-list .pick:hover {
background: #f0fdfa;
}
.template-list .pick strong {
font-size: 0.82rem;
}
.template-list .pick span {
color: #64748b;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-list .del {
flex: none;
border: 0;
border-radius: 8px;
background: transparent;
color: #b91c1c;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
padding: 0.35rem 0.45rem;
}
.tpl-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
margin-top: 0.15rem;
padding-top: 0.35rem;
border-top: 1px solid #eef2f7;
}
.tpl-fields-label {
color: #64748b;
font-size: 0.74rem;
margin-inline-end: 0.15rem;
}
.tpl-fields button {
border: 1px solid color-mix(in srgb, var(--accent) 28%, #fff);
background: #fff;
color: var(--accent-deep);
border-radius: 999px;
font: inherit;
font-size: 0.74rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
.tpl-fields code {
color: inherit;
}
.save-row {
display: flex;
gap: 0.3rem;
margin-top: 0.2rem;
}
.save-row input {
flex: 1;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
font: inherit;
}
.save-row button {
flex: none;
border: 0;
border-radius: 8px;
background: #0f766e;
color: #fff;
font: inherit;
font-size: 0.78rem;
padding: 0.4rem 0.65rem;
cursor: pointer;
}
.save-row button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
textarea {
width: 100%;
box-sizing: border-box;
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
import { LeftOutlined } from '@ant-design/icons-vue';
export type StagePathItem = {
id: string;
name: string;
};
const props = withDefaults(
defineProps<{
stages: StagePathItem[];
value?: string | null;
loading?: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(),
{
value: null,
loading: false,
disabled: false,
ariaLabel: 'مراحل',
},
);
const emit = defineEmits<{
'update:value': [id: string];
select: [id: string];
}>();
const { token } = theme.useToken();
const currentIndex = computed(() =>
props.stages.findIndex((stage) => stage.id === props.value),
);
const stripStyle = computed(() => ({
width: '100%',
overflowX: 'auto' as const,
paddingBottom: '2px',
}));
const sepStyle = computed(() => ({
color: token.value.colorTextQuaternary,
fontSize: '10px',
flexShrink: 0,
}));
function onPick(id: string) {
if (props.disabled || props.loading || id === props.value) return;
emit('update:value', id);
emit('select', id);
}
</script>
<template>
<div
role="listbox"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
:style="stripStyle"
>
<a-flex align="center" :gap="6" wrap="nowrap" :style="{ width: 'max-content', minWidth: '100%' }">
<template v-for="(stage, index) in stages" :key="stage.id">
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
<a-button
role="option"
:aria-selected="stage.id === value"
:type="stage.id === value ? 'primary' : 'default'"
:disabled="disabled || loading"
:style="{
borderRadius: '999px',
flexShrink: 0,
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
opacity: index < currentIndex ? 0.85 : 1,
}"
@click="onPick(stage.id)"
>
{{ stage.name }}
</a-button>
</template>
</a-flex>
</div>
</template>
+14 -5
View File
@@ -18,8 +18,10 @@ const props = withDefaults(
tableKey: string;
label?: string;
align?: 'start' | 'end';
/** فقط آیکون تنظیمات (بدون متن «ستون‌ها») */
iconOnly?: boolean;
}>(),
{ label: 'ستون‌ها', align: 'start' },
{ label: 'ستون‌ها', align: 'start', iconOnly: false },
);
const open = ref(false);
@@ -60,11 +62,18 @@ const placement = computed(() =>
</a-button>
</a-flex>
</template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
<a-button
:type="hiddenCount > 0 ? 'primary' : 'default'"
:ghost="hiddenCount > 0"
:title="iconOnly ? (hiddenCount ? `${label} · ${toFaDigits(hiddenCount)} مخفی` : label) : undefined"
:aria-label="label"
>
<template #icon><SettingOutlined /></template>
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
<template v-if="!iconOnly">
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</template>
</a-button>
</a-popover>
+90 -4
View File
@@ -22,6 +22,7 @@ import {
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { matchesSearch } from '@/lib/search';
const props = defineProps<{
users: OrgUserDto[];
@@ -74,6 +75,29 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
const teamsQuery = ref('');
const accessQuery = ref('');
const filteredTeams = computed(() => {
const q = teamsQuery.value.trim();
if (!q) return teams.value;
return teams.value.filter((team) =>
matchesSearch(q, [
team.name,
team.description,
...team.members.map((m) => m.fullName),
]),
);
});
const filteredAccessUsers = computed(() => {
const q = accessQuery.value.trim();
if (!q) return editableUsers.value;
return editableUsers.value.filter((u) =>
matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName, accessSummary(u)]),
);
});
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
priorPick.value = '';
@@ -383,14 +407,22 @@ onMounted(() => {
</section>
<section class="card">
<h3>تیم‌ها</h3>
<header class="card-head">
<h3>تیم‌ها</h3>
<UiInput
v-if="teams.length"
v-model="teamsQuery"
placeholder="جستجوی تیم یا عضو…"
/>
</header>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<article v-for="t in filteredTeams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
@@ -504,7 +536,14 @@ onMounted(() => {
</section>
<section class="card">
<h3>محدوده داده</h3>
<header class="card-head">
<h3>محدوده داده</h3>
<UiInput
v-if="editableUsers.length"
v-model="accessQuery"
placeholder="جستجوی کاربر…"
/>
</header>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
@@ -514,8 +553,9 @@ onMounted(() => {
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
@@ -567,6 +607,22 @@ onMounted(() => {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.card-head h3 {
margin: 0;
}
.card-head :deep(.field),
.card-head :deep(input) {
min-width: min(16rem, 100%);
max-width: 18rem;
}
.prior-head {
display: flex;
align-items: center;
@@ -722,5 +778,35 @@ select {
.prior-picker {
grid-template-columns: 1fr;
}
.card-head {
flex-direction: column;
align-items: stretch;
}
.card-head :deep(.field),
.card-head :deep(input) {
max-width: none;
min-width: 0;
width: 100%;
}
.team header {
flex-direction: column;
align-items: stretch;
}
.team .actions {
width: 100%;
}
.vis-row {
align-items: start;
}
.vis-badge {
display: inline-block;
margin: 0.25rem 0 0;
}
}
</style>
+214 -21
View File
@@ -1,20 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import { computed, ref, watch } from 'vue';
import { Button as AButton, Select as ASelect } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
type PickUser = { id: string; fullName: string };
type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
type AssigneeGroup = { id: string; name: string; userIds: string[] };
type GroupKind = 'role' | 'team';
const props = withDefaults(
defineProps<{
modelValue: string[];
users: PickUser[];
/** انتخاب یک نقش، همهٔ اعضایش را مسئول می‌کند */
roles?: AssigneeGroup[];
/** انتخاب یک تیم، همهٔ اعضایش را مسئول می‌کند */
teams?: AssigneeGroup[];
disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number;
addLabel?: string;
emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' },
{
roles: () => [],
teams: () => [],
disabled: false,
min: 0,
addLabel: 'افزودن',
emptyLabel: 'کسی انتخاب نشده',
selectAll: false,
},
);
const emit = defineEmits<{
@@ -22,32 +39,208 @@ const emit = defineEmits<{
change: [ids: string[]];
}>();
const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
const pinned = ref(new Set<string>());
function uniqueIds(ids: string[]) {
return [...new Set(ids.filter(Boolean))];
}
function normalizeSearch(value: string) {
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
}
const catalog = computed(() => {
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
for (const group of groups) {
const userIds = uniqueIds(group.userIds);
if (!userIds.length || !group.id || !group.name) continue;
rows.push({
token: `${kind}:${group.id}`,
name: group.name,
userIds,
kind,
});
}
};
push('role', props.roles);
push('team', props.teams);
return rows;
});
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
function covers(userIds: string[], selected: string[]) {
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
}
const shownGroups = computed(() =>
catalog.value.filter((group) => {
if (!covers(group.userIds, props.modelValue)) return false;
if (group.userIds.length >= 2) return true;
return pinned.value.has(group.token);
}),
);
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
const selectValue = computed(() => {
const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
return [
...shownGroups.value.map((group) => group.token),
...props.modelValue.filter((id) => !covered.has(id)),
];
});
const options = computed(() => {
const people = props.users.map((user) => ({
value: user.id,
label:
catalog.value.length && user.orgRoleName
? `${user.fullName} — ${user.orgRoleName}`
: user.fullName,
}));
if (!catalog.value.length) return people;
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
const section = (kind: GroupKind, title: string) => {
const items = catalog.value
.filter((group) => group.kind === kind)
.map((group) => ({
value: group.token,
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
}));
if (items.length) grouped.push({ label: title, options: items });
};
section('role', 'نقش‌ها');
section('team', 'تیم‌ها');
if (people.length) grouped.push({ label: 'اعضا', options: people });
return grouped;
});
function filterOption(input: string, option?: { label?: unknown }) {
const label = String(option?.label ?? '');
if (!label || !input.trim()) return true;
return normalizeSearch(label).includes(normalizeSearch(input));
}
const allPicked = computed(
() =>
props.users.length > 0 &&
props.users.every((user) => props.modelValue.includes(user.id)),
);
const tagLimit = computed(() => {
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
return undefined;
});
function tagPlaceholder(omitted: unknown[]) {
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
const extra = Array.isArray(omitted) ? omitted.length : 0;
if (!extra) return '';
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
}
watch(
() => props.modelValue.join('\0'),
() => {
const next = new Set<string>();
for (const token of pinned.value) {
const group = catalogByToken.value.get(token);
if (group && covers(group.userIds, props.modelValue)) next.add(token);
}
pinned.value = next;
},
);
function sameIds(left: string[], right: string[]) {
if (left.length !== right.length) return false;
const have = new Set(left);
return right.every((id) => have.has(id));
}
function commit(ids: string[]) {
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
emit('update:modelValue', ids);
emit('change', ids);
if (!sameIds(ids, props.modelValue)) emit('change', ids);
}
function onUpdate(value: unknown) {
const tokens = Array.isArray(value) ? value.map(String) : [];
const tokenSet = new Set(tokens);
const removedMemberIds = new Set<string>();
for (const token of selectValue.value) {
if (tokenSet.has(token)) continue;
const dropped = catalogByToken.value.get(token);
if (!dropped) continue;
for (const id of dropped.userIds) removedMemberIds.add(id);
}
const ids = new Set<string>();
const pins = new Set<string>();
for (const token of tokens) {
const group = catalogByToken.value.get(token);
if (group) {
pins.add(token);
for (const id of group.userIds) {
if (removedMemberIds.has(id)) continue;
ids.add(id);
}
continue;
}
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
}
const next = [...ids];
if (next.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
pinned.value = pins;
commit(next);
}
function pickAll() {
if (props.disabled || allPicked.value) return;
commit(props.users.map((user) => user.id));
}
</script>
<template>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
<div class="user-multi" :class="{ 'with-all': selectAll }">
<a-select
mode="multiple"
allow-clear
show-search
:value="selectValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
:filter-option="filterOption"
:max-tag-count="tagLimit"
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
style="width: 100%"
@update:value="onUpdate"
/>
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
همه
</a-button>
</div>
</template>
<style scoped>
.user-multi.with-all {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.user-multi.with-all :deep(.ant-select) {
flex: 1 1 auto;
min-width: 0;
}
.user-multi.with-all :deep(.ant-btn) {
flex: 0 0 auto;
}
</style>
@@ -9,9 +9,10 @@ import type {
} from '@mento/shared';
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
@@ -41,6 +42,7 @@ const emit = defineEmits<{
stagesChanged: [];
add: [stageId: string];
error: [message: string];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string];
bulkDelete: [];
@@ -63,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 });
const quickId = ref<string | null>(null);
const peekId = ref<string | null>(null);
let peekLinger = 0;
const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined,
@@ -88,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback;
}
function dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const due = new Date(item.dueAt);
const days = calendarDaysBetween(due, new Date());
const day =
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
const late =
item.status === 'open' &&
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length
@@ -112,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
date:
item.status === 'done'
? 'انجام شده'
: item.dueAt
? formatRelativeDayLabel(item.dueAt)
: 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title),
stageId,
createdAt: item.createdAt,
@@ -121,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
activityStatus: item.status,
};
}
@@ -222,12 +259,12 @@ function onMenuActivity(id: string) {
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
draggingColumnId.value = null;
// قفل/صف مانده از درخواست قبلی را باز کن
if (moving.value) resetMoveLock();
dragSessionId = deal.id;
// setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
@@ -235,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
}
function onDragEnd() {
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
clearDragState();
}, 0);
}
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -255,17 +322,19 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId) return;
const from = columns.value[fromStageId]!;
const from = columns.value[fromStageId];
const to = columns.value[toStageId];
if (!to) return;
if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex];
if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!;
const toStage = props.stages.find((s) => s.id === toStageId);
if (!toStage) return;
const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return;
let insertAt = index == null || index < 0 ? 0 : index;
// بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return;
@@ -287,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
}
moving.value = true;
armMoveWatchdog();
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
position: insertAt,
});
if (!same && data.status !== toStage.kind) {
const hideFromMine = props.params.desk === 'mine';
from.items = hideFromMine
? prevFrom.filter((row) => row.id !== activityId)
: prevFrom.map((row) => (row.id === activityId ? data : row));
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
to.items = prevTo;
to.total = prevToTotal;
emit(
'notice',
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
);
emit('changed');
return;
}
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
@@ -304,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
window.clearTimeout(moveWatchdog);
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
dropStageId.value = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
const transfer = event.dataTransfer?.getData('text/plain') || '';
const cardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value;
clearDragState();
// کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
const activityId =
cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return;
}
if (!raw || moving.value) return;
await moveCard(raw, stageId, index);
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
}
async function persistOrder(ids: string[]) {
@@ -404,7 +497,7 @@ async function onAddStage(name: string) {
}
}
function openActivity(id: string) {
function activityQuery(id: string) {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
@@ -418,13 +511,38 @@ function openActivity(id: string) {
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.priority) query.priority = source.priority;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
if (source.due) query.due = source.due;
const columnId = findStageOf(id);
if (columnId) query.stage = columnId;
void router.push({ name: 'activity-detail', params: { id }, query });
return query;
}
function openActivity(id: string) {
if (quickId.value === id) {
closeQuick();
return;
}
window.clearTimeout(peekLinger);
quickId.value = id;
peekId.value = id;
}
function closeQuick() {
quickId.value = null;
const id = peekId.value;
window.clearTimeout(peekLinger);
peekLinger = window.setTimeout(() => {
if (!quickId.value && peekId.value === id) peekId.value = null;
}, 650);
}
function openFullActivity(id: string) {
quickId.value = null;
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
}
</script>
@@ -440,23 +558,32 @@ function openActivity(id: string) {
:copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode"
:selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@detail-deal="openFullActivity"
@menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -17,6 +17,8 @@ const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>();
const emit = defineEmits<{
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<div class="backdrop" aria-hidden="true" />
<section
class="sheet"
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
export type DeskFilterField =
| 'title'
| 'who'
| 'customer'
| 'type'
| 'priority'
| 'when'
| 'status';
const props = defineProps<{
/** یک ستون، یا پنل کامل بالای صفحه */
@@ -10,11 +17,13 @@ const props = defineProps<{
customer: string;
due: '' | 'set' | 'unset';
typeKey: string;
priorityKey: string;
statusKey: string;
assigneeId: string;
showAssignee: boolean;
users: Array<{ id: string; fullName: string }>;
types: Array<{ key: string; label: string }>;
priorities: Array<{ key: string; label: string; color?: string }>;
statuses: Array<{ key: string; label: string }>;
}>();
@@ -23,6 +32,7 @@ const emit = defineEmits<{
customer: [value: string];
due: [value: '' | 'set' | 'unset'];
type: [value: string];
priority: [value: string];
status: [value: string];
assignee: [value: string];
clear: [];
@@ -86,6 +96,7 @@ const hasAny = computed(
() =>
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
props.typeKey !== 'all' ||
props.priorityKey !== 'all' ||
props.statusKey !== 'all',
);
@@ -101,6 +112,9 @@ const whoLabel = computed(
const typeLabel = computed(
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
);
const priorityLabel = computed(
() => props.priorities.find((item) => item.key === props.priorityKey)?.label || 'همه',
);
const statusLabel = computed(
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
);
@@ -144,6 +158,11 @@ function pickType(next: string) {
finish();
}
function pickPriority(next: string) {
emit('priority', props.priorityKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickStatus(next: string) {
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
finish();
@@ -282,6 +301,36 @@ function pickAssignee(next: string) {
</div>
</section>
<section v-if="shows('priority')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: priorityKey !== 'all' }"
@click="toggleSection('priority')"
>
<span>اولویت</span>
<em>{{ priorityLabel }}</em>
</button>
<div v-if="opened('priority')" class="pick" role="listbox" aria-label="اولویت">
<button
v-for="opt in priorities"
:key="opt.key"
type="button"
:class="{ on: priorityKey === opt.key }"
@click="pickPriority(opt.key)"
>
<span
v-if="opt.color && opt.key !== 'all'"
class="prio-dot"
:style="{ background: opt.color }"
aria-hidden="true"
/>
{{ opt.label }}
</button>
</div>
</section>
<section v-if="shows('when')">
<button
v-if="field === 'all'"
@@ -434,6 +483,9 @@ function pickAssignee(next: string) {
}
.pick button {
display: flex;
align-items: center;
gap: 0.4rem;
text-align: start;
border-radius: 8px;
padding: 0.42rem 0.5rem;
@@ -450,6 +502,13 @@ function pickAssignee(next: string) {
font-weight: 700;
}
.prio-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex: none;
}
.seg {
display: flex;
gap: 0.25rem;
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
const props = defineProps<{
dueAt?: string | null;
saving?: boolean;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>();
const emit = defineEmits<{
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" />
@@ -0,0 +1,706 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Row as ARow,
Select as ASelect,
Image as AImage,
Spin as ASpin,
Typography,
theme,
} from 'ant-design-vue';
import type { ActivityDto } from '@mento/shared';
import { api } from '@/lib/api';
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
import { noteChips } from '@/lib/lead-form-notes';
import {
formatClockFa,
formatJalaliDay,
formatJalaliDayClock,
formatJalaliNumeric,
formatLocalDateTimeInput,
parseLocalInput,
startOfLocalDay,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
const props = defineProps<{
activityId: string | null;
}>();
const emit = defineEmits<{
close: [];
changed: [];
full: [id: string];
}>();
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
const { token } = theme.useToken();
const auth = useAuthStore();
const rendered = ref(false);
const placed = ref(false);
const panelEl = ref<HTMLElement | null>(null);
const frame = ref({ left: 12, top: 12, width: 440, maxHeight: 560, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
function cardEl(id: string) {
return document.querySelector<HTMLElement>(`.ant-card[data-card-id="${CSS.escape(id)}"]`);
}
function columnScroller(el: HTMLElement) {
let node = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
node = node.parentElement;
}
return null;
}
function panelExtent() {
const content = panelEl.value?.scrollHeight ?? 0;
return content > 80 ? content : 328;
}
/** Lift the column so a top-aligned panel fits. The last card may have no room left. */
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
const overflow = cardTop + height - (vh - VIEW_PAD);
if (overflow <= 8) return;
const scroller = columnScroller(el);
if (!scroller) return;
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
function measure() {
const id = props.activityId;
if (!id) return;
const el = cardEl(id);
if (!el) return;
let rect = el.getBoundingClientRect();
if (rect.width < 8) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const width = Math.min(440, vw - 24);
const gap = 10;
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
left = Math.max(12, Math.min(left, vw - width - 12));
const height = Math.min(panelExtent(), Math.max(160, vh - VIEW_PAD * 2));
if (reveal) {
reveal = false;
liftColumn(el, rect.top, height, vh);
rect = el.getBoundingClientRect();
}
let top = rect.top;
const maxTop = vh - VIEW_PAD - height;
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
if (top < VIEW_PAD) top = VIEW_PAD;
const maxHeight = Math.max(160, vh - VIEW_PAD - top);
const travel = Math.min(120, Math.round(width * 0.28));
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
}
const panelStyle = computed(() => ({
position: 'fixed' as const,
zIndex: 1050,
top: `${frame.value.top}px`,
left: `${frame.value.left}px`,
width: `${frame.value.width}px`,
maxHeight: `${frame.value.maxHeight}px`,
overflow: 'auto' as const,
boxSizing: 'border-box' as const,
padding: `${token.value.padding}px`,
background: token.value.colorBgElevated,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
opacity: placed.value ? 1 : 0,
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
}));
function onViewport() {
if (props.activityId) measure();
}
function onPointerDown(event: PointerEvent) {
if (!props.activityId || !placed.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (panelEl.value?.contains(target)) return;
if (commentOpen.value) return;
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
const card = cardEl(props.activityId);
if (card?.contains(target)) return;
emit('close');
}
function onKey(event: KeyboardEvent) {
if (event.key !== 'Escape' || !props.activityId) return;
if (commentOpen.value) return;
emit('close');
}
onMounted(() => {
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onViewport);
window.removeEventListener('scroll', onViewport, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.clearTimeout(hideTimer);
clearNoteMedia();
});
const activity = ref<ActivityDto | null>(null);
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const commentOpen = ref(false);
const postponeKey = ref('h1');
const customDue = ref('');
type NoteMedia = {
id: string;
name: string;
url: string;
kind: 'image' | 'audio' | 'file';
createdAt: string;
};
const noteMedia = ref<NoteMedia[]>([]);
function clearNoteMedia() {
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
noteMedia.value = [];
}
const isClosed = computed(
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
);
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
const canCollaborate = computed(() =>
canCollaborateOnActivity(activity.value, auth.user),
);
const needsEveryone = computed(
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
);
const myShareDone = computed(() => {
const mine = auth.user?.id;
return Boolean(mine && activity.value?.completedByIds?.includes(mine));
});
const actionLabel = computed(() => {
if (isClosed.value) return 'بازگشایی';
if (needsEveryone.value && myShareDone.value) return 'لغو انجام';
return 'انجام شده';
});
const createdLabel = computed(() => {
const raw = activity.value?.createdAt;
if (!raw) return '—';
const date = new Date(raw);
return `${formatJalaliDay(date)} - ${formatClockFa(date)}`;
});
const thread = computed(() => parseActivityThread(activity.value?.description));
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
const threadBlocks = computed(() => {
const entries = thread.value;
if (!entries) return null;
return bindThreadMedia(entries, noteMedia.value);
});
const looseImages = computed(() =>
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
);
const noteScrollStyle = computed(() => ({
maxHeight: '220px',
overflow: 'auto',
}));
const noteBubbleStyle = computed(() => ({
padding: '8px 12px',
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadius}px`,
background: token.value.colorFillAlter,
}));
const postponeBase = computed(() => {
const now = new Date();
const due = activity.value?.dueAt ? new Date(activity.value.dueAt) : now;
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
return due;
});
function addMinutes(mins: number) {
return new Date(postponeBase.value.getTime() + mins * 60_000);
}
function tomorrowMorning() {
const d = startOfLocalDay(new Date());
d.setDate(d.getDate() + 1);
d.setHours(10, 0, 0, 0);
return d;
}
function nextSaturdayMorning() {
const d = startOfLocalDay(new Date());
const untilSaturday = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
d.setHours(10, 0, 0, 0);
return d;
}
const postponeChoices = computed(() => {
const h1 = addMinutes(60);
const morning = tomorrowMorning();
const saturday = nextSaturdayMorning();
const week = new Date(postponeBase.value.getTime());
week.setDate(week.getDate() + 7);
return [
{ key: 'h1', label: `یک ساعت دیرتر · ${formatClockFa(h1)}`, at: h1 },
{ key: 'tomorrow-am', label: `فردا صبح · ${formatClockFa(morning)}`, at: morning },
{ key: 'next-saturday', label: `شنبه آینده · ${formatJalaliDayClock(saturday)}`, at: saturday },
{ key: 'next-week', label: `هفته آینده · ${formatJalaliDayClock(week)}`, at: week },
{ key: 'custom', label: 'زمان دلخواه', at: null },
];
});
const postponeReady = computed(() => {
if (postponeKey.value === 'custom') return Boolean(parseLocalInput(customDue.value));
return postponeChoices.value.some((item) => item.key === postponeKey.value && item.at);
});
function resetDrafts() {
commentOpen.value = false;
postponeKey.value = 'h1';
customDue.value = '';
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
async function loadNoteMedia(row: ActivityDto) {
clearNoteMedia();
const next: NoteMedia[] = [];
for (const image of row.images ?? []) {
const mime = image.mimeType.toLowerCase();
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
try {
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(data);
if (props.activityId !== row.id) {
URL.revokeObjectURL(url);
continue;
}
next.push({
id: image.id,
name: image.fileName,
url,
kind,
createdAt: image.createdAt,
});
} catch {
/* فایل در دسترس نیست */
}
}
if (props.activityId !== row.id) {
for (const item of next) URL.revokeObjectURL(item.url);
return;
}
noteMedia.value = next;
await nextTick();
measure();
}
async function load(id: string) {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ActivityDto>(`/activities/${id}`);
if (props.activityId !== id) return;
activity.value = data;
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
void loadNoteMedia(data);
} catch (e) {
if (props.activityId === id) {
activity.value = null;
error.value = apiMessage(e, 'بارگذاری فعالیت ناموفق بود.');
}
} finally {
if (props.activityId === id) {
loading.value = false;
reveal = true;
await nextTick();
measure();
}
}
}
watch(
() => props.activityId,
async (id, prev) => {
if (!id) {
activity.value = null;
error.value = '';
resetDrafts();
clearNoteMedia();
placed.value = false;
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
if (!props.activityId) rendered.value = false;
}, 230);
return;
}
window.clearTimeout(hideTimer);
if (prev && prev !== id) {
resetDrafts();
activity.value = null;
clearNoteMedia();
}
if (prev && prev !== id && rendered.value) placed.value = false;
rendered.value = true;
measure();
await nextTick();
measure();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (props.activityId === id) placed.value = true;
});
});
void load(id);
},
{ immediate: true },
);
async function runAction() {
const id = props.activityId;
const row = activity.value;
if (!id || !row || saving.value) return;
saving.value = true;
error.value = '';
try {
if (isClosed.value) {
if (!canMutate.value) {
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
return;
}
await api.patch(`/activities/${id}`, { status: 'open' });
await load(id);
emit('changed');
return;
}
const undo = needsEveryone.value && myShareDone.value;
const { data } = await api.patch<ActivityDto>(`/activities/${id}/complete`, undefined, {
params: undo ? { undo: '1' } : undefined,
});
if (data.status === 'done') {
emit('changed');
emit('close');
return;
}
activity.value = data;
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ثبت تغییر ناموفق بود.');
} finally {
saving.value = false;
}
}
async function postponeTo(iso: string) {
const id = props.activityId;
if (!id || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.patch(`/activities/${id}`, { dueAt: iso });
await load(id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'تعویق فعالیت ناموفق بود.');
} finally {
saving.value = false;
}
}
async function submitComment(payload: { text: string; photoOnly: boolean }) {
const id = props.activityId;
const row = activity.value;
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
if (!id || !row || !body) return;
saving.value = true;
error.value = '';
try {
const prev = row.description?.trim();
const now = new Date();
const author = auth.user?.fullName?.trim();
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
const entry = `${stamp}\n${body}`;
await api.patch(`/activities/${id}`, { description: prev ? `${prev}\n\n${entry}` : entry });
commentOpen.value = false;
await load(id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ثبت نظر ناموفق بود.');
throw e;
} finally {
saving.value = false;
}
}
async function applyPostpone() {
if (postponeKey.value === 'custom') {
const at = parseLocalInput(customDue.value);
if (!at) return;
await postponeTo(at.toISOString());
return;
}
const at = postponeChoices.value.find((item) => item.key === postponeKey.value)?.at;
if (at) await postponeTo(at.toISOString());
}
function openFull() {
if (!props.activityId) return;
emit('full', props.activityId);
}
</script>
<template>
<Teleport to="body">
<div
v-if="rendered"
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="activity?.title || 'جزئیات فعالیت'"
:style="panelStyle"
>
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
<a-button type="text" aria-label="بستن" @click="emit('close')">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<a-spin :spinning="loading">
<a-alert v-if="error" type="error" :message="error" show-icon :style="{ marginBottom: '12px' }" />
<template v-if="activity">
<a-row :gutter="[16, 12]">
<a-col :xs="24" :sm="12">
<a-flex vertical :gap="2">
<a-typography-text type="secondary">ایجاد شده توسط</a-typography-text>
<a-typography-text>{{ activity.createdBy?.fullName || '—' }}</a-typography-text>
</a-flex>
</a-col>
<a-col :xs="24" :sm="12">
<a-flex vertical :gap="2">
<a-typography-text type="secondary">تاریخ ایجاد</a-typography-text>
<a-typography-text>{{ createdLabel }}</a-typography-text>
</a-flex>
</a-col>
</a-row>
<a-divider :style="{ margin: '16px 0 12px' }" />
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
<a-typography-text strong>یادداشت</a-typography-text>
<a-button
type="primary"
shape="circle"
aria-label="یادداشت جدید"
:disabled="!canCollaborate"
@click="commentOpen = true"
>
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
<div class="note-photos" :style="noteScrollStyle">
<a-flex v-if="threadBlocks" vertical :gap="8">
<div
v-for="(entry, index) in threadBlocks.blocks"
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
:style="noteBubbleStyle"
>
<a-flex v-if="entry.dateLabel" justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">
{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}
</a-typography-text>
<a-typography-text v-if="entry.author" strong :style="{ color: token.colorPrimary }">
{{ entry.author }}
</a-typography-text>
</a-flex>
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
<a-image
v-if="chip.kind === 'image'"
:src="chip.src"
:alt="chip.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
:style="{ display: 'block', marginTop: '8px' }"
/>
<a-typography-link
v-else-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>
{{ chip.label }}
</a-typography-link>
<span v-else>{{ chip.text }}</span>
</template>
</a-typography-paragraph>
<a-flex
v-if="entry.media.some((item) => item.kind === 'image')"
wrap="wrap"
:gap="8"
:style="{ marginTop: '8px' }"
>
<a-image
v-for="image in entry.media.filter((item) => item.kind === 'image')"
:key="image.id"
:src="image.url"
:alt="image.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
/>
</a-flex>
</div>
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
<a-image :src="image.url" :alt="image.name" :width="72"
:height="72" :preview="{ zIndex: 1300 }" />
</div>
</a-flex>
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
<a-image
v-if="chip.kind === 'image'"
:src="chip.src"
:alt="chip.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
:style="{ display: 'block', marginTop: '8px' }"
/>
<a-typography-link
v-else-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>
{{ chip.label }}
</a-typography-link>
<span v-else>{{ chip.text }}</span>
</template>
</a-typography-paragraph>
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
<a-image
v-for="image in looseImages"
:key="image.id"
:src="image.url"
:alt="image.name"
:width="72"
:height="72"
:preview="{ zIndex: 1300 }"
/>
</a-flex>
</div>
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
</div>
<a-flex vertical :gap="8" :style="{ marginTop: '12px' }">
<a-flex align="center" :gap="8">
<a-select
v-model:value="postponeKey"
:disabled="saving || loading || isClosed || !canCollaborate"
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
:dropdown-style="{ zIndex: 1200 }"
style="flex: 1"
/>
<a-button
type="primary"
:disabled="saving || loading || isClosed || !canCollaborate || !postponeReady"
@click="applyPostpone"
>
تعویق
</a-button>
</a-flex>
<JalaliDateInput
v-if="postponeKey === 'custom'"
v-model="customDue"
mode="datetime"
placeholder="تاریخ و ساعت"
:disabled="!canCollaborate"
/>
</a-flex>
<a-divider :style="{ margin: '16px 0 12px' }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-button
type="primary"
:disabled="saving || loading || (isClosed && !canMutate)"
@click="runAction"
>
{{ actionLabel }}
</a-button>
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
</a-flex>
</template>
</a-spin>
<ActivityCommentSheet
v-if="commentOpen && activity"
:activity="activity"
:saving="saving"
:overlay-z-index="1200"
:save="submitComment"
@close="commentOpen = false"
/>
</div>
</Teleport>
</template>
<style scoped>
.note-photos :deep(.ant-image) {
width: 72px;
height: 72px;
}
.note-photos :deep(.ant-image-img) {
width: 72px;
height: 72px;
object-fit: cover;
}
</style>
@@ -4,6 +4,7 @@ import axios from 'axios';
import type {
ActivityFieldDef,
ActivityFieldType,
ActivityPrioritySetting,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
@@ -17,9 +18,11 @@ import {
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ACTIVITY_TYPE_ICON_LABELS,
newFieldKey,
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
type MgrTab = 'types' | 'priorities';
const tab = ref<MgrTab>('types');
const prioritiesApi = useActivityPriorities();
const priorityDraft = ref<ActivityPrioritySetting[]>(
DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })),
);
const prioritySaved = ref('');
const priorityDirty = computed(
() => JSON.stringify(priorityDraft.value) !== prioritySaved.value,
);
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string };
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
notice.value = '';
}
async function loadPrioritiesTab() {
await prioritiesApi.load(true);
priorityDraft.value = prioritiesApi.items.value.map((r) => ({ ...r }));
prioritySaved.value = JSON.stringify(priorityDraft.value);
}
function switchTab(next: MgrTab) {
if (next === tab.value) return;
if (tab.value === 'types' && dirty.value && draft.value) {
if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return;
}
if (tab.value === 'priorities' && priorityDirty.value) {
if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return;
}
tab.value = next;
error.value = '';
notice.value = '';
if (next === 'priorities') void loadPrioritiesTab();
}
async function savePriorities() {
for (const row of priorityDraft.value) {
if (!row.label.trim()) {
error.value = 'عنوان همهٔ سطوح اولویت را بنویسید.';
return;
}
if (!/^#[0-9a-fA-F]{6}$/.test(row.color)) {
error.value = 'رنگ باید به صورت #RRGGBB باشد.';
return;
}
}
busy.value = 'prio';
error.value = '';
notice.value = '';
try {
const { data } = await api.put<ActivityPrioritySetting[]>('/activities/priorities', {
items: priorityDraft.value.map((r) => ({
key: r.key,
label: r.label.trim(),
color: r.color,
})),
});
prioritiesApi.setLocal(data);
priorityDraft.value = data.map((r) => ({ ...r }));
prioritySaved.value = JSON.stringify(priorityDraft.value);
notice.value = 'اولویت‌ها ذخیره شد.';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ اولویت‌ها ناموفق بود.');
} finally {
busy.value = '';
}
}
function resetPriorities() {
if (!window.confirm('عنوان و رنگ‌ها به پیش‌فرض برگردند؟')) return;
priorityDraft.value = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r }));
}
watch(
() => props.open,
async (open) => {
if (!open) return;
error.value = '';
notice.value = '';
tab.value = 'types';
await load(true);
await prioritiesApi.load(true);
const first = types.value[0];
selectedId.value = null;
draft.value = null;
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
}
function close() {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
if (tab.value === 'types' && dirty.value && draft.value) {
if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
}
if (tab.value === 'priorities' && priorityDirty.value) {
if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. بستن؟')) return;
}
emit('close');
}
</script>
@@ -291,6 +371,28 @@ function close() {
<template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr">
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: tab === 'types' }"
:aria-selected="tab === 'types'"
@click="switchTab('types')"
>
انواع و فیلدها
</button>
<button
type="button"
role="tab"
:class="{ on: tab === 'priorities' }"
:aria-selected="tab === 'priorities'"
@click="switchTab('priorities')"
>
اولویت‌ها
</button>
</div>
<template v-if="tab === 'types'">
<p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
@@ -479,6 +581,43 @@ function close() {
</section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div>
</template>
<div v-else class="prio">
<p class="hint">
پنج سطح اولویت ثابت است؛ فقط عنوان و رنگ را عوض کنید. تغییر عنوان روی همهٔ فعالیت‌ها و کارهای
روتین قبلی هم اعمال می‌شود (کلید داخلی عوض نمی‌شود).
</p>
<ul class="prio-list">
<li v-for="row in priorityDraft" :key="row.key" class="prio-row">
<span class="swatch" :style="{ background: row.color }" aria-hidden="true" />
<label class="f grow">
<span>عنوان</span>
<input v-model="row.label" maxlength="40" />
</label>
<label class="f color">
<span>رنگ</span>
<input v-model="row.color" type="color" :aria-label="`رنگ ${row.label}`" />
</label>
</li>
</ul>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="actions">
<UiButton
variant="primary"
type="button"
:disabled="!priorityDirty || Boolean(busy)"
:loading="busy === 'prio'"
@click="savePriorities"
>
ذخیرهٔ اولویت‌ها
</UiButton>
<UiButton variant="ghost" type="button" :disabled="Boolean(busy)" @click="resetPriorities">
بازگشت به پیش‌فرض
</UiButton>
</div>
</div>
</div>
</UiModal>
</template>
@@ -489,6 +628,78 @@ function close() {
gap: 0.9rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tabs button {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.85rem;
font: inherit;
font-size: 0.84rem;
cursor: pointer;
color: #475569;
}
.tabs button.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.prio {
display: grid;
gap: 0.85rem;
}
.prio-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.prio-row {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.swatch {
width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
margin-bottom: 0.55rem;
flex-shrink: 0;
border: 1px solid rgba(15, 23, 42, 0.12);
}
.prio-row .grow {
flex: 1;
min-width: 10rem;
}
.prio-row .color input[type='color'] {
width: 3rem;
height: 2.1rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: none;
cursor: pointer;
}
.hint {
margin: 0;
color: #64748b;
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
LeftOutlined,
PhoneOutlined,
PlusOutlined,
ScheduleOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
const { token } = theme.useToken();
const STATUS_LABELS: Record<ActivityStatus, string> = {
open: 'باز',
done: 'انجام‌شده',
canceled: 'لغو شده',
};
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
call: PhoneOutlined,
meeting: TeamOutlined,
task: CheckCircleOutlined,
note: ScheduleOutlined,
};
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
const props = withDefaults(
defineProps<{
dealId: string;
personId?: string | null;
companyId?: string | null;
dealTitle?: string;
}>(),
{
personId: null,
companyId: null,
dealTitle: '',
},
);
const router = useRouter();
const items = ref<ActivityDto[]>([]);
const loading = ref(false);
const error = ref('');
const closedKeys = ref<string[]>([]);
function sortByDue(a: ActivityDto, b: ActivityDto) {
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
}
const planned = computed(() =>
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
);
const closed = computed(() =>
items.value
.filter((a) => a.status !== 'open')
.slice()
.sort((a, b) => {
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
return tb.localeCompare(ta);
}),
);
function dueTone(dueAt: string | null): DueTone {
if (!dueAt) return 'none';
const due = new Date(dueAt);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
if (days < 0) return 'overdue';
if (days === 0) return 'today';
if (days <= 2) return 'soon';
return 'later';
}
function dueLabel(dueAt: string | null) {
if (!dueAt) return 'بدون موعد';
const d = new Date(dueAt);
if (Number.isNaN(d.getTime())) return 'بدون موعد';
const tone = dueTone(dueAt);
if (tone === 'overdue') {
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
}
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
}
function dueTagColor(tone: DueTone) {
if (tone === 'overdue') return 'error';
if (tone === 'today') return 'warning';
if (tone === 'soon') return 'processing';
return 'default';
}
function rowStyle(tone: DueTone, closedRow = false) {
const base = {
width: '100%',
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
cursor: 'pointer' as const,
opacity: closedRow ? 0.78 : 1,
};
if (closedRow) {
return { ...base, background: token.value.colorFillAlter };
}
if (tone === 'overdue') {
return {
...base,
borderColor: token.value.colorErrorBorder,
background: token.value.colorErrorBg,
};
}
if (tone === 'today') {
return {
...base,
borderColor: token.value.colorWarningBorder,
background: token.value.colorWarningBg,
};
}
return base;
}
function avatarStyle(tone: DueTone) {
if (tone === 'overdue') {
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
}
if (tone === 'today') {
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
}
return {
backgroundColor: token.value.colorPrimaryBg,
color: token.value.colorPrimary,
};
}
function typeLabel(item: ActivityDto) {
const type = item.type as ActivityType;
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
}
function statusLabel(status: ActivityDto['status']) {
return STATUS_LABELS[status as ActivityStatus];
}
function typeIcon(type: ActivityDto['type']) {
return TYPE_ICONS[type as ActivityType];
}
function newActivityQuery() {
const query: Record<string, string> = { dealId: props.dealId };
if (props.personId) query.personId = props.personId;
if (props.companyId) query.companyId = props.companyId;
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
return query;
}
function goCreate() {
void router.push({ name: 'activity-new', query: newActivityQuery() });
}
function goDetail(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
}
async function load() {
if (!props.dealId) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', dealId: props.dealId, limit: 50 },
});
items.value = data.items ?? [];
} catch {
error.value = 'بارگذاری فعالیت‌های معامله ممکن نشد.';
items.value = [];
} finally {
loading.value = false;
}
}
watch(
() => props.dealId,
() => {
void load();
},
{ immediate: true },
);
</script>
<template>
<a-card :bordered="false">
<template #title>
<a-space :size="8">
<CalendarOutlined :style="{ color: token.colorPrimary }" />
<span>فعالیت‌های برنامه‌ریزی‌شده</span>
<a-tag v-if="planned.length" color="processing" :bordered="false">
{{ toFaDigits(planned.length) }} باز
</a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن فعالیت
</a-button>
</template>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginBottom: `${token.margin}px` }"
/>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !planned.length"
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
:style="{ paddingBlock: `${token.paddingLG}px` }"
>
<template #description>
<a-flex vertical align="center" :gap="token.marginXS">
<a-typography-title :level="5" :style="{ margin: 0 }">
هنوز فعالیتی برای این معامله نیست
</a-typography-title>
<a-typography-paragraph
type="secondary"
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
>
یک تماس، جلسه یا پیگیری زمان‌بندی کن تا معامله از دست نرود.
</a-typography-paragraph>
</a-flex>
</template>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن اولین فعالیت
</a-button>
</a-empty>
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
<a-flex
v-for="item in planned"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle(dueTone(item.dueAt))"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
<template #icon>
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
<component :is="typeIcon(item.type)" v-else />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
{{ dueLabel(item.dueAt) }}
</a-tag>
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
{{ item.assignee.fullName }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
<template v-if="closed.length">
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
<a-collapse
v-model:activeKey="closedKeys"
ghost
:bordered="false"
:style="{
background: 'transparent',
marginTop: planned.length ? 0 : `${token.marginSM}px`,
}"
>
<a-collapse-panel key="closed">
<template #header>
<a-typography-text type="secondary">
{{ toFaDigits(closed.length) }} فعالیت انجام‌شده یا لغو شده
</a-typography-text>
</template>
<a-flex vertical :gap="token.marginSM">
<a-flex
v-for="item in closed"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle('none', true)"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex
align="center"
:gap="token.marginSM"
:style="{ minWidth: 0, flex: '1 1 220px' }"
>
<a-avatar
:size="40"
:style="{
backgroundColor: token.colorFillSecondary,
color: token.colorTextSecondary,
}"
>
<template #icon>
<component :is="typeIcon(item.type)" />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
<a-typography-text type="secondary">
{{ dueLabel(item.dueAt) }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
</a-collapse-panel>
</a-collapse>
</template>
</a-spin>
</a-card>
</template>
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { hasSectionPermission } from '@/lib/section-permissions';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
@@ -25,9 +27,13 @@ type BirthdayMonth = {
items: BirthdayItem[];
};
const auth = useAuthStore();
const data = ref<BirthdayMonth | null>(null);
const listOpen = ref(false);
const smsTarget = ref<BirthdayItem | null>(null);
const canSeeCustomers = computed(() =>
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
);
const groups = computed(() => {
const map = new Map<number, BirthdayItem[]>();
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
}
onMounted(async () => {
if (!canSeeCustomers.value) {
data.value = null;
return;
}
try {
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
data.value = body.total > 0 ? body : null;
+189 -51
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
LeftOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -18,10 +19,12 @@ import {
Typography,
theme,
} from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
const props = withDefaults(
defineProps<{
@@ -31,6 +34,9 @@ const props = withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selected?: boolean;
peeking?: boolean;
/** پنل کناری همین کارت باز است؛ فلش را برمی‌گرداند. جدا از هایلایتِ بعد از بستن. */
previewing?: boolean;
}>(),
{ copy: () => DEAL_BOARD_COPY },
);
@@ -39,6 +45,7 @@ const emit = defineEmits<{
dragStart: [deal: PipelineDeal, event: DragEvent];
dragEnd: [];
open: [dealId: string];
detail: [dealId: string];
menu: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -47,6 +54,11 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const activityOpen = ref(false);
const highlighted = computed(
() => props.selected || props.peeking || activityOpen.value,
);
const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null,
);
@@ -63,26 +75,97 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#eab308' },
low: { label: 'کم', color: '#16a34a' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const dateHint = computed(() => {
if (!props.deal.dateIso) return props.deal.date;
const d = new Date(props.deal.dateIso);
if (Number.isNaN(d.getTime())) return props.deal.date;
return formatJalaliNumeric(d);
});
const activityPriorities = useActivityPriorities();
onMounted(() => {
void activityPriorities.load();
});
const priorityFlag = computed(() => {
const key = props.deal.priority;
if (!key) return null;
return {
label: activityPriorities.labelOf(key),
color: activityPriorities.colorOf(key),
};
});
function onTitleClick() {
if (props.selectMode) {
emit('toggleSelect', props.deal.id);
return;
}
if (props.copy.quickView) emit('detail', props.deal.id);
else emit('open', props.deal.id);
}
/** موعد: (فقط فعالیت) انجام‌شده سبز · عقب‌افتاده قرمز · امروز زرد · آینده/بی‌موعد خاکستری */
const dueTone = computed<'done' | 'overdue' | 'today' | 'upcoming' | 'none'>(() => {
if (props.copy.quickView && props.deal.activityStatus === 'done') return 'done';
if (!props.deal.dateIso) return 'none';
const due = new Date(props.deal.dateIso);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(new Date(), due);
if (days < 0) return 'overdue';
if (days === 0) return 'today';
return 'upcoming';
});
const dueToneColor = computed(() => {
if (dueTone.value === 'done') return token.value.colorSuccess;
if (dueTone.value === 'overdue') return token.value.colorError;
if (dueTone.value === 'today') return token.value.colorWarning;
return token.value.colorTextSecondary;
});
const chevronBtnStyle = computed(() => ({
boxSizing: 'border-box' as const,
width: '22px',
height: '22px',
margin: 0,
padding: 0,
border: `1.5px solid ${dueToneColor.value}`,
borderRadius: '50%',
background: 'transparent',
color: dueToneColor.value,
display: 'inline-grid',
placeItems: 'center',
cursor: 'pointer',
lineHeight: 0,
flexShrink: 0,
}));
const chevronIconStyle = computed(() => ({
fontSize: '10px',
width: '10px',
height: '10px',
lineHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: props.previewing ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s ease',
}));
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1,
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
// تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو می‌کند؛ فقط سایه
opacity: props.dragging ? 0.55 : 1,
transition: 'opacity 120ms ease, box-shadow 120ms ease',
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
position: highlighted.value ? 'relative' : undefined,
zIndex: highlighted.value ? 2 : undefined,
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: highlighted.value
? `0 0 0 2px ${token.value.colorPrimary}`
: props.dragging
? token.value.boxShadow
: token.value.boxShadowTertiary,
}));
</script>
@@ -124,21 +207,29 @@ const cardStyle = computed(() => ({
</template>
</a-button>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
ellipsis
:style="{
display: 'block',
margin: 0,
lineHeight: 1.35,
cursor: 'pointer',
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
}"
:title="deal.title"
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
>
{{ deal.title }}
</a-typography-text>
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
</a-tooltip>
<a-typography-text
ellipsis
:style="{
flex: 1,
minWidth: 0,
display: 'block',
margin: 0,
lineHeight: 1.35,
cursor: 'pointer',
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
}"
:title="deal.title"
@click.stop="onTitleClick"
@dblclick.stop
>
{{ deal.title }}
</a-typography-text>
</a-flex>
<a-typography-text
v-if="deal.description"
type="secondary"
@@ -193,28 +284,75 @@ const cardStyle = computed(() => ({
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }}
</a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
<a-tooltip :title="dateHint">
<a-typography-text
:type="dueTone === 'none' ? 'secondary' : undefined"
:style="{
fontSize: `${token.fontSizeSM}px`,
color: dueTone !== 'none' ? dueToneColor : undefined,
}"
>
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-tooltip>
</a-space>
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap' }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text>
</a-typography-text>
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
<button
v-if="copy.quickView && !selectMode"
type="button"
class="deal-card-peek"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing"
:style="chevronBtnStyle"
@click.stop="emit('open', deal.id)"
>
<LeftOutlined :style="chevronIconStyle" />
</button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
<span
v-if="copy.quickActivity && !selectMode"
@click.stop
@mousedown.stop
>
<DealQuickActivityPopover
:deal="deal"
@update:open="activityOpen = $event"
/>
</span>
</a-tooltip>
</a-space>
</a-flex>
</a-flex>
</a-card>
</template>
<style scoped>
/* وسط‌چین دقیق آیکون داخل دایره (anticon پیش‌فرض vertical-align دارد) */
.deal-card-peek :deep(.anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
vertical-align: 0;
line-height: 0;
}
.deal-card-peek :deep(.anticon > svg) {
display: block;
width: 10px;
height: 10px;
}
</style>
@@ -0,0 +1,571 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Badge as ABadge,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
CloseOutlined,
FormOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import type {
ActivityDto,
ActivityType,
CreateActivityRequest,
DealActivityCue,
DealActivitySummaryDto,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import {
formatLocalDateInput,
formatRelativeDayLabel,
formatClockFa,
toFaDigits,
} from '@/lib/jalali';
import type { PipelineDeal } from '@/lib/pipeline-board';
const { Title: ATypographyTitle, Link: ATypographyLink, Text: ATypographyText } =
Typography;
const props = defineProps<{
deal: PipelineDeal;
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:open': [open: boolean];
}>();
const router = useRouter();
const { token } = theme.useToken();
type PanelMode = 'list' | 'create';
const open = ref(false);
const rendered = ref(false);
const placed = ref(false);
const panelEl = ref<HTMLElement | null>(null);
const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
const PREVIEW_LIMIT = 3;
const mode = ref<PanelMode>('create');
const listExpanded = ref(false);
const saving = ref(false);
const error = ref('');
const title = ref('');
const type = ref<ActivityType>('task');
const dueDate = ref('');
const dueTime = ref('');
const localCue = ref<DealActivityCue | null>(null);
const localSummaries = ref<DealActivitySummaryDto[] | null>(null);
const summaries = computed(
() => localSummaries.value ?? props.deal.activitySummaries ?? [],
);
const activityCue = computed<DealActivityCue>(
() => localCue.value ?? props.deal.activityCue ?? 'none',
);
const openCount = computed(
() => summaries.value.filter((item) => item.status === 'open').length,
);
const visibleSummaries = computed(() =>
listExpanded.value ? summaries.value : summaries.value.slice(0, PREVIEW_LIMIT),
);
const hiddenCount = computed(() =>
Math.max(0, summaries.value.length - PREVIEW_LIMIT),
);
const cueMeta = computed(() => {
switch (activityCue.value) {
case 'overdue':
return { color: token.value.colorError, title: 'فعالیت عقب‌افتاده' };
case 'done':
return { color: token.value.colorSuccess, title: 'فعالیت انجام‌شده' };
case 'none':
return { color: token.value.colorWarning, title: 'بدون فعالیت — افزودن' };
default:
return { color: token.value.colorTextSecondary, title: 'فعالیت‌های معامله' };
}
});
const typeOptions = computed(() =>
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
.filter(([value]) => value !== 'note')
.map(([value, label]) => ({ value, label })),
);
const fieldItemStyle = computed(() => ({
marginBottom: 0,
width: '100%',
}));
const panelTitle = computed(() =>
mode.value === 'list' ? 'فعالیت‌های معامله' : 'ایجاد فعالیت سریع',
);
watch(
() => [props.deal.activityCue, props.deal.activitySummaries] as const,
() => {
localCue.value = null;
localSummaries.value = null;
},
);
function resetForm() {
title.value = `پیگیری: ${props.deal.title}`;
type.value = 'task';
dueDate.value = formatLocalDateInput(new Date());
dueTime.value = '';
error.value = '';
}
function cueDotColor(cue: Exclude<DealActivityCue, 'none'>) {
if (cue === 'overdue') return token.value.colorError;
if (cue === 'done') return token.value.colorSuccess;
return token.value.colorTextSecondary;
}
function activityWhen(item: DealActivitySummaryDto) {
if (!item.dueAt) return 'بدون موعد';
const d = new Date(item.dueAt);
if (Number.isNaN(d.getTime())) return '—';
const day = formatRelativeDayLabel(item.dueAt);
return `${day} · ${formatClockFa(d)}`;
}
function cardEl() {
return document.querySelector<HTMLElement>(
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
);
}
function columnScroller(el: HTMLElement) {
let node = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
node = node.parentElement;
}
return null;
}
function panelExtent() {
const content = panelEl.value?.scrollHeight ?? 0;
return content > 80 ? content : 360;
}
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
const overflow = cardTop + height - (vh - VIEW_PAD);
if (overflow <= 8) return;
const scroller = columnScroller(el);
if (!scroller) return;
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
function measure() {
const el = cardEl();
if (!el) return;
let rect = el.getBoundingClientRect();
if (rect.width < 8) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const width = Math.min(360, vw - 24);
const gap = 10;
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
left = Math.max(12, Math.min(left, vw - width - 12));
const height = Math.min(panelExtent(), Math.max(200, vh - VIEW_PAD * 2));
if (reveal) {
reveal = false;
liftColumn(el, rect.top, height, vh);
rect = el.getBoundingClientRect();
}
let top = rect.top;
const maxTop = vh - VIEW_PAD - height;
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
if (top < VIEW_PAD) top = VIEW_PAD;
const maxHeight = Math.max(200, vh - VIEW_PAD - top);
const travel = Math.min(100, Math.round(width * 0.28));
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
}
const panelStyle = computed(() => ({
position: 'fixed' as const,
zIndex: 1050,
top: `${frame.value.top}px`,
left: `${frame.value.left}px`,
width: `${frame.value.width}px`,
maxHeight: `${frame.value.maxHeight}px`,
overflow: 'auto' as const,
boxSizing: 'border-box' as const,
padding: `${token.value.padding}px`,
background: token.value.colorBgElevated,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
opacity: placed.value ? 1 : 0,
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
}));
function setOpen(next: boolean) {
if (open.value === next) return;
open.value = next;
emit('update:open', next);
}
function toggleOpen() {
if (props.disabled) return;
setOpen(!open.value);
}
function close() {
setOpen(false);
}
function openCreate() {
mode.value = 'create';
resetForm();
void nextTick().then(() => measure());
}
function backToList() {
mode.value = 'list';
error.value = '';
void nextTick().then(() => measure());
}
function goActivity(id: string) {
close();
void router.push({ name: 'activity-detail', params: { id } });
}
function onViewport() {
if (open.value) measure();
}
function onPointerDown(event: PointerEvent) {
if (!open.value || !placed.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (panelEl.value?.contains(target)) return;
if (
target instanceof Element &&
target.closest('.ant-select-dropdown, #jalali-picker, .layer')
) {
return;
}
const card = cardEl();
if (card?.contains(target)) return;
close();
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && open.value) close();
}
watch(open, async (isOpen) => {
if (!isOpen) {
placed.value = false;
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
if (!open.value) rendered.value = false;
}, 230);
return;
}
window.clearTimeout(hideTimer);
listExpanded.value = false;
mode.value = summaries.value.length ? 'list' : 'create';
resetForm();
rendered.value = true;
reveal = true;
measure();
await nextTick();
measure();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (open.value) {
placed.value = true;
void nextTick().then(() => measure());
}
});
});
});
onMounted(() => {
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onViewport);
window.removeEventListener('scroll', onViewport, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.clearTimeout(hideTimer);
});
function dueAtIso(): string | null {
if (!dueDate.value) return null;
const time = dueTime.value.trim() || '10:30';
const due = new Date(`${dueDate.value}T${time}:00`);
return Number.isNaN(due.getTime()) ? null : due.toISOString();
}
function fullFormQuery() {
const query: Record<string, string> = { dealId: props.deal.id };
if (props.deal.personId) query.personId = props.deal.personId;
if (props.deal.companyId) query.companyId = props.deal.companyId;
if (title.value.trim()) query.title = title.value.trim();
if (type.value) query.type = type.value;
if (dueAtIso()) query.dueAt = dueAtIso()!;
return query;
}
function goFullForm() {
close();
void router.push({ name: 'activity-new', query: fullFormQuery() });
}
async function submit() {
error.value = '';
if (title.value.trim().length < 2) {
error.value = 'عنوان فعالیت را بنویسید.';
return;
}
const dueAt = dueAtIso();
if (!dueDate.value || !dueAt) {
error.value = 'تاریخ فعالیت را مشخص کنید.';
return;
}
const body: CreateActivityRequest = {
title: title.value.trim(),
type: type.value,
dueAt,
dealId: props.deal.id,
personId: props.deal.personId || null,
companyId: props.deal.companyId || null,
assigneeId: props.deal.ownerId || null,
};
saving.value = true;
try {
const { data } = await api.post<ActivityDto>('/activities', body);
message.success('فعالیت ثبت شد');
const dueMs = new Date(dueAt).getTime();
const cue: Exclude<DealActivityCue, 'none'> =
Number.isFinite(dueMs) && dueMs < Date.now() ? 'overdue' : 'upcoming';
const created: DealActivitySummaryDto = {
id: data.id,
title: data.title,
type: data.type,
status: data.status === 'done' ? 'done' : 'open',
dueAt: data.dueAt,
cue,
};
const next = [created, ...summaries.value.filter((item) => item.id !== created.id)];
localSummaries.value = next;
localCue.value =
next.some((item) => item.cue === 'overdue')
? 'overdue'
: next.some((item) => item.status === 'open')
? 'upcoming'
: next.some((item) => item.cue === 'done')
? 'done'
: 'none';
mode.value = 'list';
void nextTick().then(() => measure());
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
</script>
<template>
<a-tooltip :title="cueMeta.title">
<a-badge
:count="openCount"
:offset="[-2, 2]"
:number-style="{ fontSize: '10px', minWidth: '16px', height: '16px', lineHeight: '16px' }"
>
<a-button
type="text"
:disabled="disabled"
aria-label="فعالیت‌های معامله"
:aria-expanded="open"
:style="{
color: open ? token.colorPrimary : cueMeta.color,
width: '28px',
minWidth: '28px',
height: '28px',
padding: 0,
}"
@click.stop="toggleOpen"
@mousedown.stop
@dragstart.stop.prevent
>
<template #icon><CalendarOutlined /></template>
</a-button>
</a-badge>
</a-tooltip>
<Teleport to="body">
<div
v-if="rendered"
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="`${panelTitle} برای ${deal.title}`"
:style="panelStyle"
>
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
<a-typography-title :level="5" :style="{ margin: 0 }">{{ panelTitle }}</a-typography-title>
<a-space :size="4">
<a-button
v-if="mode === 'list'"
type="text"
aria-label="فعالیت جدید"
@click="openCreate"
>
<template #icon><PlusOutlined /></template>
</a-button>
<a-button type="text" aria-label="بستن" @click="close">
<template #icon><CloseOutlined /></template>
</a-button>
</a-space>
</a-flex>
<template v-if="mode === 'list'">
<a-flex vertical :gap="8">
<a-card
v-for="item in visibleSummaries"
:key="item.id"
hoverable
:bordered="true"
:body-style="{ padding: '10px 12px' }"
@click="goActivity(item.id)"
>
<a-flex align="flex-start" :gap="10">
<span
aria-hidden="true"
:style="{
width: '8px',
height: '8px',
marginTop: '6px',
borderRadius: '999px',
background: cueDotColor(item.cue),
flexShrink: 0,
}"
/>
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text strong :style="{ display: 'block' }">
{{ item.title }}
</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ ACTIVITY_TYPE_LABELS[item.type] || item.type }}
·
{{ activityWhen(item) }}
</a-typography-text>
</div>
</a-flex>
</a-card>
<a-typography-link
v-if="!listExpanded && hiddenCount > 0"
@click="listExpanded = true"
>
مشاهده همه ({{ toFaDigits(summaries.length) }})
</a-typography-link>
</a-flex>
</template>
<a-flex v-else vertical :gap="token.marginSM">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-form-item label="عنوان" :style="fieldItemStyle">
<a-input v-model:value="title" placeholder="عنوان فعالیت" allow-clear />
</a-form-item>
<a-form-item label="نوع" :style="fieldItemStyle">
<a-select v-model:value="type" :options="typeOptions" style="width: 100%" />
</a-form-item>
<a-row :gutter="[12, 12]" align="top">
<a-col :span="12">
<a-form-item label="تاریخ" :style="fieldItemStyle">
<JalaliDateInput v-model="dueDate" placeholder="تاریخ" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="ساعت" :style="fieldItemStyle">
<JalaliDateInput
v-model="dueTime"
mode="time"
placeholder="اختیاری"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-typography-link v-if="summaries.length" @click="backToList">
بازگشت به لیست
</a-typography-link>
<a-typography-link @click="goFullForm">
<FormOutlined />
فرم کامل
</a-typography-link>
</a-space>
<a-space :size="8">
<a-button :disabled="saving" @click="summaries.length ? backToList() : close()">
انصراف
</a-button>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</a-space>
</a-flex>
</a-flex>
</div>
</Teleport>
</template>
@@ -21,8 +21,10 @@ withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
peekId?: string | null;
previewId?: string | null;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
);
const emit = defineEmits<{
@@ -32,6 +34,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string];
openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -109,6 +112,8 @@ function submitAdd() {
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="previewId"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@@ -117,6 +122,7 @@ function submitAdd() {
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@detail-deal="emit('detailDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@@ -26,8 +26,10 @@ const props = withDefaults(
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
peekId?: string | null;
previewId?: string | null;
}>(),
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null, previewId: null },
);
const emit = defineEmits<{
@@ -37,6 +39,7 @@ const emit = defineEmits<{
drop: [stageId: string, event: DragEvent, index?: number];
addDeal: [stageId: string];
openDeal: [dealId: string];
detailDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
@@ -314,9 +317,12 @@ function onMenuClick({ key }: { key: string | number }) {
:copy="copy"
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
:peeking="peekId === deal.id"
:previewing="previewId === deal.id"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="clearDragUi(); emit('dragEnd')"
@open="emit('openDeal', $event)"
@detail="emit('detailDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@@ -15,11 +15,13 @@ import {
Space as ASpace,
Table as ATable,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import type {
PipelineAccent,
PipelineDeal,
@@ -82,6 +84,13 @@ function money(deal: PipelineDeal) {
return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال');
}
function dateHint(deal: PipelineDeal) {
if (!deal.dateIso) return deal.date;
const d = new Date(deal.dateIso);
if (Number.isNaN(d.getTime())) return deal.date;
return formatJalaliNumeric(d);
}
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
@@ -213,10 +222,12 @@ onUnmounted(() => {
</span>
</template>
<template v-else-if="column.key === 'date'">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
<a-tooltip :title="dateHint(record.deal)">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
</a-tooltip>
</template>
<template v-else-if="column.key === 'actions'">
<span :data-card-id="record.deal.id">
@@ -334,10 +345,12 @@ onUnmounted(() => {
{{ row.stageTitle }}
</a-tag>
<a-flex justify="space-between" align="center">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
<a-tooltip :title="dateHint(row.deal)">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
</a-tooltip>
<span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
@@ -24,9 +24,9 @@ import {
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX,
apiError,
localInputToIso,
@@ -68,10 +68,10 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
);
const categoryOptions = computed(() => [
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS,
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorities = computed(() =>
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
);
function startEdit() {
const t = task.value;
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }}
اولویت {{ activityPriorities.labelOf(task.priority) }}
</UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div>
@@ -23,9 +23,9 @@ import {
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
entityRoute,
@@ -64,6 +64,9 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag :bordered="false">{{ activityPriorities.labelOf(task.priority) }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</a-tag>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => {
const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance';
});
watch(
active,
async () => {
await nextTick();
scroller.value
?.querySelector('.ant-segmented-item-selected')
?.scrollIntoView({ inline: 'center', block: 'nearest' });
},
{ immediate: true },
);
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template>
<div
ref="scroller"
:style="{
width: '100%',
overflowX: 'auto',
@@ -1,14 +1,24 @@
<script setup lang="ts">
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 { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{
rows: TreasuryPartyBalanceDto[];
settledLabel: string;
}>();
const isMobile = useTreasuryMobile();
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
@@ -39,10 +49,43 @@ const columns = computed(() => [
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
},
]);
function kindOf(record: TreasuryPartyBalanceDto) {
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
}
</script>
<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
v-else
:columns="columns"
:data-source="rows"
:pagination="false"
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId');
@@ -156,12 +161,12 @@ onMounted(() => {
:filter-option="false"
:loading="loading"
:value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر"
:placeholder="placeholder"
style="width: 100%"
@search="onSearch"
@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-option
v-for="row in peopleOptions"
+37 -2
View File
@@ -184,8 +184,43 @@ function onSizeChange(e: Event) {
@media (max-width: 900px) {
.ui-pagination {
flex-direction: column;
align-items: center;
gap: var(--sp-3);
align-items: stretch;
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>
+12 -6
View File
@@ -21,6 +21,7 @@ import type {
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import {
canOpenPath,
firstAllowedFinancePath,
hasSectionPermission,
sectionPermissionForPath,
} from '@/lib/section-permissions';
@@ -204,12 +205,11 @@ function cardAllowedForNav(card: {
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
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')) {
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 hasSection = section
@@ -246,7 +246,13 @@ const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
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 (
hub.key === 'customers' &&
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)} روز پیش`;
}
/** تاریخ نسبی تقویمی برای کارت‌ها: امروز / ۳ روز عقب افتاده / ۵ روز مانده */
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 {
if (!iso) return '—';
+32 -3
View File
@@ -1,6 +1,11 @@
import { currencySuffix } from '@/lib/money';
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';
@@ -10,6 +15,8 @@ export type PipelineDeal = {
description: string;
amount: number | null;
date: string;
/** ISO برای tooltip تاریخ مطلق */
dateIso?: string;
initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string;
@@ -20,10 +27,18 @@ export type PipelineDeal = {
assigneeNames?: string[];
ownerId?: string | null;
assigneeIds?: string[];
personId?: string | null;
companyId?: string | null;
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
/** وضعیت فعالیت (برای کارت کانبان فعالیت: انجام‌شده و …) */
activityStatus?: ActivityStatus;
/** نشانگر وضعیت فعالیت معامله روی دکمهٔ تقویم */
activityCue?: DealActivityCue;
/** خلاصه فعالیت‌ها برای پاپ‌آپ دکمهٔ تقویم */
activitySummaries?: DealActivitySummaryDto[];
};
export type PipelineUiCopy = {
@@ -34,6 +49,10 @@ export type PipelineUiCopy = {
itemMenuAria: string;
listColTitle: string;
hideAmount?: boolean;
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه به‌جای ورود مستقیم به جزئیات */
quickView?: boolean;
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
quickActivity?: boolean;
};
export const DEAL_BOARD_COPY: PipelineUiCopy = {
@@ -43,6 +62,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله',
quickActivity: true,
};
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
@@ -63,6 +83,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت',
hideAmount: true,
quickView: true,
};
export type PipelineStage = {
@@ -182,8 +203,10 @@ export function mapBoardToStages(
expectedCloseAt: string | null;
createdAt: string;
promotedFromPriorAt: string | null;
person: { fullName: string } | null;
company: { name: string } | null;
activityCue?: DealActivityCue;
activitySummaries?: DealActivitySummaryDto[];
person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
@@ -226,6 +249,9 @@ export function mapBoardToStages(
description,
amount: deal.amount ?? 0,
date: formatDate(dateIso),
dateIso,
activityCue: deal.activityCue ?? 'none',
activitySummaries: deal.activitySummaries ?? [],
initials: initialsFromTitle(
deal.company?.name || deal.title,
),
@@ -236,6 +262,8 @@ export function mapBoardToStages(
assigneeNames,
ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
personId: deal.person?.id ?? null,
companyId: deal.company?.id ?? null,
searchFields: [
deal.notes,
deal.company?.name,
@@ -291,6 +319,7 @@ export function mapProjectBoardToStages(
'',
amount: null,
date: formatDate(card.createdAt),
dateIso: card.createdAt,
initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title,
),
+1
View File
@@ -29,6 +29,7 @@ export {
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
/** پیش‌فرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
+24
View File
@@ -69,6 +69,30 @@ export function canOpenFinancePath(
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(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
+8 -8
View File
@@ -2,7 +2,12 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/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({
history: createWebHistory(),
@@ -683,13 +688,8 @@ router.beforeEach((to) => {
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true;
}
const exactHome = to.path === '/finance' || to.path === '/finance/';
if (
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
const next = firstAllowedFinancePath(auth.user?.role, auth.user?.permissions);
if (next && next !== to.path) return { path: next };
}
return { name: 'home' };
}
+6
View File
@@ -214,6 +214,12 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) void hydrate();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void hydrate();
});
}
return {
token,
user,
+143 -45
View File
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
ACTIVITY_EXCEL_ALIASES,
@@ -42,6 +43,7 @@ import type {
ActivityBoardDto,
ActivityDto,
ActivityFilter,
ActivityPriority,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
@@ -50,9 +52,12 @@ import type {
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { ACTIVITY_PRIORITY_KEYS, brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
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 { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -91,6 +96,7 @@ const actGridStyle = computed(() => ({
/** 'all' یا شناسهٔ نوع فعالیت */
type TypeFilter = string;
type PriorityFilter = 'all' | ActivityPriority;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
@@ -118,6 +124,18 @@ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'canceled', label: 'لغو شده' },
];
const activityPriorities = useActivityPriorities();
const PRIORITY_FILTERS = computed<
Array<{ key: PriorityFilter; label: string; color?: string }>
>(() => [
{ key: 'all', label: 'همه اولویت‌ها' },
...activityPriorities.items.value.map((row) => ({
key: row.key as PriorityFilter,
label: row.label,
color: row.color,
})),
]);
const pageSize = usePageSize('activities', 10);
const route = useRoute();
@@ -177,6 +195,7 @@ const boardDeleteOpen = ref(false);
async function onTypesChanged() {
await activityTypes.load(true);
await activityPriorities.load(true);
kanbanReload.value += 1;
await refreshAll();
}
@@ -201,6 +220,7 @@ const page = computed(() => {
const loading = ref(false);
const error = ref('');
const notice = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]);
@@ -239,6 +259,13 @@ const statusFilter = computed<StatusFilter>(() => {
: 'all';
});
const priorityFilter = computed<PriorityFilter>(() => {
const value = typeof route.query.priority === 'string' ? route.query.priority : 'all';
return ACTIVITY_PRIORITY_KEYS.includes(value as ActivityPriority)
? (value as ActivityPriority)
: 'all';
});
const assigneeFilter = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
@@ -317,6 +344,7 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
? legacyBaseType.value
: undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
priority: priorityFilter.value === 'all' ? undefined : priorityFilter.value,
assigneeId: assigneeFilter.value || undefined,
title: titleFilter.value.trim() || undefined,
customer: customerFilter.value.trim() || undefined,
@@ -329,6 +357,7 @@ function stageDotStyle(s: ActivityStageDto) {
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
if (priorityFilter.value !== 'all') n += 1;
if (statusFilter.value !== 'all') n += 1;
if (assigneeFilter.value) n += 1;
if (titleFilter.value.trim()) n += 1;
@@ -431,6 +460,7 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
if (search) query.q = search;
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
if (statusFilter.value !== 'all') query.status = statusFilter.value;
if (priorityFilter.value !== 'all') query.priority = priorityFilter.value;
if (assigneeFilter.value) query.assigneeId = assigneeFilter.value;
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
@@ -445,23 +475,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
return query;
}
function activityRoute(id: string, edit = false, 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;
}
function activityRoute(id: string, columnStageId?: string) {
return {
name: 'activity-edit' as const,
name: 'activity-detail' as const,
params: { id },
query,
query: deskDetailQuery(columnStageId),
};
}
@@ -781,6 +799,13 @@ function setStatusFilter(next: string) {
patchFacetQuery({ status: value === 'all' ? undefined : value });
}
function setPriorityFilter(next: string) {
const value: PriorityFilter = ACTIVITY_PRIORITY_KEYS.includes(next as ActivityPriority)
? (next as ActivityPriority)
: 'all';
patchFacetQuery({ priority: value === 'all' ? undefined : value });
}
function setAssigneeFilter(next: string) {
patchFacetQuery({
assigneeId: next || undefined,
@@ -805,6 +830,7 @@ function clearFacets() {
type: undefined,
typeDefId: undefined,
status: undefined,
priority: undefined,
assigneeId: undefined,
title: undefined,
customer: undefined,
@@ -837,6 +863,30 @@ function onFacetDone() {
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) {
menuId.value = null;
boardMenuId.value = null;
@@ -913,10 +963,27 @@ function clearSelect() {
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) {
menuId.value = null;
boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id];
}
@@ -926,8 +993,24 @@ function askBulkDelete() {
menuId.value = null;
boardMenuId.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 = '';
deleteActivityIds.value = [...selectedIds.value];
deleteActivityIds.value = allowed;
}
async function confirmDeleteActivities() {
@@ -937,16 +1020,24 @@ async function confirmDeleteActivities() {
error.value = '';
try {
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 = [];
clearSelect();
kanbanReload.value += 1;
await refreshAll();
if (failed) {
if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value =
failed === ids.length
? 'حذف فعالیت‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
failed.length === ids.length
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
}
} finally {
activityDeleting.value = false;
@@ -1105,6 +1196,7 @@ onMounted(() => {
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void activityTypes.load(true);
void activityPriorities.load();
void loadBoards()
.then(() => loadStages())
.then(() => {
@@ -1160,11 +1252,11 @@ onUnmounted(() => {
variant="ghost"
size="sm"
type="button"
title="انواع فعالیت و فیلدها"
title="انواع فعالیت و اولویت ها"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="14" />
<span class="manage-label">انواع فعالیت و فیلدها</span>
<span class="manage-label">انواع فعالیت و اولویت ها</span>
</UiButton>
<UiButton
v-if="canManageStages"
@@ -1323,7 +1415,7 @@ onUnmounted(() => {
<hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" />
انواع فعالیت و فیلدها
انواع فعالیت و اولویت ها
</button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" />
@@ -1402,16 +1494,19 @@ onUnmounted(() => {
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@clear="clearFacets"
@@ -1503,6 +1598,7 @@ onUnmounted(() => {
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="notice" class="ok">{{ notice }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<div v-if="selectMode" class="select-bar">
@@ -1532,6 +1628,7 @@ onUnmounted(() => {
@stages-changed="onStagesChanged"
@add="openAddInStage"
@error="(msg: string) => (error = msg)"
@notice="(msg: string) => (notice = msg)"
@menu-deal="onBoardMenu"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@@ -1619,16 +1716,19 @@ onUnmounted(() => {
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@done="onFacetDone"
@@ -1749,16 +1849,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</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 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>
</UiDropdownMenu>
@@ -1839,16 +1941,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</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 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>
</UiDropdownMenu>
@@ -1895,18 +1999,11 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
>
مشاهده
</RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button"
@@ -1916,6 +2013,7 @@ onUnmounted(() => {
انتقال به کار جاری
</button>
<button
v-if="canDeleteActivity(boardMenuActivity)"
type="button"
role="menuitem"
class="danger"
File diff suppressed because it is too large Load Diff
+136 -30
View File
@@ -32,6 +32,7 @@ import {
UiTextarea,
} from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityBoardDto,
@@ -89,6 +90,8 @@ const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const title = ref('');
const type = ref<ActivityType | ''>('');
const typeDefId = ref('');
@@ -129,6 +132,8 @@ const personId = ref('');
const leadId = ref('');
const dealId = ref('');
const assigneeIds = ref<string[]>([]);
const completionMode = ref<'any' | 'all'>('any');
const editPolicy = ref<'creator' | 'assignees'>('creator');
const isDone = ref(false);
const needsNote = ref(false);
const reminderAt = ref('');
@@ -269,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
return false;
}
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
{ value: 'high', label: 'زیاد', tone: 'high' },
{ value: 'medium', label: 'متوسط', tone: 'medium' },
{ value: 'low', label: 'کم', tone: 'low' },
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
];
const priorityOptions = computed(() =>
activityPriorities.items.value.map((row) => ({
value: row.key,
label: row.label,
color: row.color,
})),
);
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
const typeFields = computed(() => typeMeta.value?.fields ?? []);
@@ -400,7 +405,7 @@ const duePreview = computed(() => {
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
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 summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
@@ -440,6 +445,8 @@ function resetForm() {
personId.value = '';
dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
completionMode.value = 'any';
editPolicy.value = 'creator';
pickedRoleIds.value = [];
pickedTeamIds.value = [];
isDone.value = false;
@@ -528,6 +535,8 @@ async function load() {
: auth.user?.id
? [auth.user.id]
: [];
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
refreshPickedGroups();
isDone.value = data.status === 'done';
await loadSavedPhotos(data);
@@ -541,6 +550,16 @@ async function load() {
personId.value = incomingPerson;
}
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 =
typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId)
@@ -605,6 +624,8 @@ function payload(): CreateActivityRequest {
leadId: leadId.value || null,
assigneeIds: [...assigneeIds.value],
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;
createdActivityId.value = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
await api.patch(`/activities/${savedId}/complete`, undefined, {
params: { force: '1' },
});
}
} else {
await api.patch(`/activities/${savedId}`, {
@@ -1269,6 +1292,20 @@ watch([id, isNew], () => void load(), { immediate: true });
</li>
</ul>
</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">
{{
canAssignOthers
@@ -1302,6 +1339,29 @@ watch([id, isNew], () => void load(), { immediate: true });
</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="field">
<div class="label-row">
@@ -1385,7 +1445,13 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="card">
<p class="pri-label">اولویت</p>
<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" />
<UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span>
@@ -1799,6 +1865,66 @@ watch([id, isNew], () => void load(), { immediate: true });
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 {
display: flex;
flex-wrap: wrap;
@@ -2248,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
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 {
font-weight: 600;
}
+63
View File
@@ -18,6 +18,7 @@ import {
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Modal,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
@@ -44,9 +45,11 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedActivitiesPanel from '@/components/activities/RelatedActivitiesPanel.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import type {
@@ -104,6 +107,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
const loading = ref(false);
const saving = ref(false);
const stageMoving = ref(false);
const error = ref('');
const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']);
@@ -197,6 +201,43 @@ const pipelineSelectOptions = computed(() =>
const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })),
);
const stagePathItems = computed(() => stages.value.map((s) => ({ id: s.id, name: s.name })));
function onStageSelect(nextId: string) {
if (!nextId || nextId === stageId.value || stageMoving.value || saving.value) return;
const target = stages.value.find((stage) => stage.id === nextId);
if (!target) return;
const prev = stageId.value;
Modal.confirm({
title: 'جابه‌جایی مرحله',
content: `به مرحله «${target.name}» منتقل می‌شود. انجام شود؟`,
okText: 'انجام بده',
cancelText: 'انجام نده',
centered: true,
async onOk() {
stageId.value = nextId;
if (isNew.value || !id.value) return;
stageMoving.value = true;
error.value = '';
try {
await api.patch(`/deals/${id.value}/move`, { stageId: nextId });
} catch (e) {
stageId.value = prev;
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'جابه‌جایی مرحله ناموفق بود.');
} else {
error.value = 'جابه‌جایی مرحله ناموفق بود.';
}
throw e;
} finally {
stageMoving.value = false;
}
},
});
}
const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })),
);
@@ -665,6 +706,20 @@ onMounted(load);
</a-space>
</a-flex>
<div
v-if="stagePathItems.length"
:style="{ marginBottom: '16px' }"
>
<StagePathStrip
:stages="stagePathItems"
:value="stageId"
:loading="stageMoving"
:disabled="saving || loading"
aria-label="مراحل مسیر فروش"
@select="onStageSelect"
/>
</div>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit">
@@ -960,6 +1015,14 @@ onMounted(load);
</a-form-item>
</a-card>
<RelatedActivitiesPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
:deal-id="id"
:person-id="personId || null"
:company-id="companyId || null"
:deal-title="title"
/>
<RelatedTasksPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
+442 -202
View File
@@ -5,12 +5,10 @@ import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Badge as ABadge,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Drawer as ADrawer,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
@@ -41,6 +39,7 @@ import type { TableColumnsType, UploadProps } from 'ant-design-vue';
import {
AppstoreOutlined,
DeleteOutlined,
DownOutlined,
EditOutlined,
ExportOutlined,
FileExcelOutlined,
@@ -55,6 +54,7 @@ import { api } from '@/lib/api';
import {
formatJalaliNumeric,
formatRelativeContact,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
@@ -298,8 +298,12 @@ const assigneeSelectValue = computed(() => {
if (unassigned.value) return UNASSIGNED_VALUE;
return assigneeId.value || undefined;
});
const moreFiltersCount = computed(() =>
/** شمارش فیلترهای داخل پاپ‌آور، مثل فعالیت‌ها. */
const mobileFilterBadgeCount = computed(() =>
[
stageId.value,
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
cityFilter.value,
regionFilter.value,
tradeFilter.value,
@@ -310,17 +314,226 @@ const moreFiltersCount = computed(() =>
convertedFilter.value ? '1' : '',
].filter(Boolean).length,
);
/** در موبایل مرتب‌سازی و مسئول و پیگیری هم داخل کشو هستند. */
const mobileFilterBadgeCount = computed(() => {
const extra = isMobile.value
? [
sort.value !== 'updated' ? '1' : '',
assigneeSelectValue.value ? '1' : '',
followUpFilter.value,
].filter(Boolean).length
: 0;
return moreFiltersCount.value + extra;
});
const filterBtnLabel = computed(() =>
mobileFilterBadgeCount.value
? `فیلتر (${toFaDigits(mobileFilterBadgeCount.value)})`
: 'فیلتر',
);
type LeadFacetKey = 'assignee' | 'stage' | 'source' | 'followUp' | 'city' | 'region' | 'trade';
const filterSection = ref<LeadFacetKey | 'createdFrom' | 'createdTo' | 'converted' | 'archived' | null>(
null,
);
const filterQuery = ref('');
const FACET_LIST_CAP = 40;
function normFilter(value: string) {
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
}
function toggleFilterSection(name: NonNullable<typeof filterSection.value>) {
filterSection.value = filterSection.value === name ? null : name;
filterQuery.value = '';
}
const filterAnchor = ref<HTMLElement | null>(null);
const filterPopoverMaxHeight = ref('min(70vh, 32rem)');
const filterPanelStyle = computed(() => ({
position: 'absolute' as const,
top: 'calc(100% + 6px)',
insetInlineStart: '0',
zIndex: 1050,
width: 'min(20rem, calc(100vw - 1.5rem))',
maxHeight: filterPopoverMaxHeight.value,
overflow: 'auto',
background: token.value.colorBgElevated,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
padding: `${token.value.paddingSM}px`,
}));
function placeFilterPopover() {
const el = filterAnchor.value;
if (!el) return;
const space = Math.floor(window.innerHeight - el.getBoundingClientRect().bottom - 12);
filterPopoverMaxHeight.value = `${Math.max(220, space)}px`;
}
function onFilterDocPointer(event: Event) {
const target = event.target;
if (!(target instanceof Node)) return;
if (filterAnchor.value?.contains(target)) return;
if (document.getElementById('jalali-picker')?.contains(target)) return;
moreFiltersOpen.value = false;
filterSection.value = null;
document.removeEventListener('pointerdown', onFilterDocPointer, true);
}
function toggleFilterPopover() {
if (moreFiltersOpen.value) {
moreFiltersOpen.value = false;
filterSection.value = null;
document.removeEventListener('pointerdown', onFilterDocPointer, true);
return;
}
placeFilterPopover();
moreFiltersOpen.value = true;
document.addEventListener('pointerdown', onFilterDocPointer, true);
}
function dateFacetLabel(value: string) {
if (!value) return 'همه';
const parsed = parseLocalInput(value);
return parsed ? formatJalaliNumeric(parsed) : value;
}
function facetValueStyle(active: boolean) {
return {
color: active ? token.value.colorPrimary : token.value.colorTextQuaternary,
fontWeight: active ? 700 : 400,
maxWidth: '9.5rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const;
}
const textFacets = computed(() => [
{
key: 'assignee' as const,
label: 'کارشناس',
value: unassigned.value
? 'بدون کارشناس'
: users.value.find((user) => user.id === assigneeId.value)?.fullName || 'همه',
active: Boolean(assigneeSelectValue.value),
current: assigneeSelectValue.value || '',
searchable: true,
options: [
{ value: '', label: 'همه' },
{ value: UNASSIGNED_VALUE, label: 'بدون کارشناس' },
...users.value.map((user) => ({ value: user.id, label: user.fullName })),
],
},
{
key: 'stage' as const,
label: 'مرحله',
value: stages.value.find((stage) => stage.id === stageId.value)?.name || 'همه',
active: Boolean(stageId.value),
current: stageId.value,
searchable: stages.value.length > 8,
options: [
{ value: '', label: 'همه' },
...stages.value.map((stage) => ({ value: stage.id, label: stage.name })),
],
},
{
key: 'source' as const,
label: 'منبع',
value: sourceFilter.value || 'همه',
active: Boolean(sourceFilter.value),
current: sourceFilter.value,
searchable: sourceOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...sourceOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'followUp' as const,
label: 'پیگیری',
value:
FOLLOW_UP_OPTIONS.find((item) => item.value === followUpFilter.value && item.value)?.label ||
'همه',
active: Boolean(followUpFilter.value),
current: followUpFilter.value,
searchable: false,
options: [
{ value: '', label: 'همه' },
...FOLLOW_UP_OPTIONS.filter((item) => item.value).map((item) => ({
value: item.value,
label: item.label,
})),
],
},
{
key: 'city' as const,
label: 'شهر',
value: cityFilter.value || 'همه',
active: Boolean(cityFilter.value),
current: cityFilter.value,
searchable: cityOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...cityOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'region' as const,
label: 'منطقه',
value: regionFilter.value || 'همه',
active: Boolean(regionFilter.value),
current: regionFilter.value,
searchable: regionOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...regionOptions.value.map((item) => ({ value: item, label: item })),
],
},
{
key: 'trade' as const,
label: 'صنف',
value: tradeFilter.value || 'همه',
active: Boolean(tradeFilter.value),
current: tradeFilter.value,
searchable: tradeOptions.value.length > 8,
options: [
{ value: '', label: 'همه' },
...tradeOptions.value.map((item) => ({ value: item, label: item })),
],
},
]);
function visibleFacetOptions(options: Array<{ value: string; label: string }>) {
const query = normFilter(filterQuery.value);
const rest = options.filter(
(item) => item.value !== '' && (!query || normFilter(item.label).includes(query)),
);
return {
options: [...options.filter((item) => item.value === ''), ...rest.slice(0, FACET_LIST_CAP)],
hidden: Math.max(0, rest.length - FACET_LIST_CAP),
empty: Boolean(query) && rest.length === 0,
};
}
function pickFacet(key: LeadFacetKey, value: string) {
const facet = textFacets.value.find((item) => item.key === key);
const next = facet && facet.current === value ? '' : value;
if (key === 'assignee') setAssigneeFilter(next || undefined);
else if (key === 'stage') setStageFilter(next);
else if (key === 'source') setSourceFilter(next);
else if (key === 'followUp') setFollowUpFilter(next || undefined);
else if (key === 'city') setCityFilter(next);
else if (key === 'region') setRegionFilter(next);
else if (key === 'trade') setTradeFilter(next);
filterSection.value = null;
}
function pickFlag(which: 'converted' | 'archived', on: boolean) {
const current = which === 'converted' ? convertedFilter.value : archivedFilter.value;
const next = on ? !current : false;
if (which === 'converted') setConvertedFilter(next);
else setArchivedFilter(next);
filterSection.value = null;
}
function facetOptionStyle(selected: boolean) {
return {
textAlign: 'start' as const,
background: selected ? token.value.colorPrimaryBg : 'transparent',
color: selected ? token.value.colorPrimary : token.value.colorText,
};
}
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
@@ -647,6 +860,10 @@ function setConvertedFilter(checked: boolean) {
function clearMoreFilters() {
patchQuery({
stageId: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
city: undefined,
region: undefined,
trade: undefined,
@@ -655,16 +872,9 @@ function clearMoreFilters() {
createdTo: undefined,
archived: undefined,
converted: undefined,
...(isMobile.value
? {
sort: undefined,
assigneeId: undefined,
unassigned: undefined,
followUp: undefined,
}
: {}),
page: undefined,
});
filterSection.value = null;
}
function setPage(next: number) {
@@ -1342,11 +1552,12 @@ onMounted(async () => {
onBeforeUnmount(() => {
stopImportPoll();
revokeLogoUrls();
document.removeEventListener('pointerdown', onFilterDocPointer, true);
});
</script>
<template>
<a-row :gutter="[16, 16]" @click="closePopups">
<a-row :gutter="[16, 16]" align="top" :style="{ alignContent: 'flex-start' }" @click="closePopups">
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
@@ -1507,12 +1718,12 @@ onBeforeUnmount(() => {
</a-space>
</a-flex>
<a-flex v-if="isMobile" vertical :gap="8">
<a-flex :vertical="isMobile" align="center" wrap="wrap" :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
style="width: 100%"
:style="isMobile ? { width: '100%' } : { width: '15rem', maxWidth: '100%' }"
@search="onSearch"
>
<template #enterButton>
@@ -1521,195 +1732,224 @@ onBeforeUnmount(() => {
</a-button>
</template>
</a-input-search>
<a-badge
:count="mobileFilterBadgeCount"
:offset="[-2, 2]"
:style="{ width: '100%', display: 'block' }"
<div
ref="filterAnchor"
:style="{ position: 'relative', width: isMobile ? '100%' : undefined }"
>
<a-button block @click="moreFiltersOpen = true">
<template #icon><FilterOutlined /></template>
فیلترها
<a-button
:block="isMobile"
:style="mobileFilterBadgeCount
? {
borderColor: token.colorPrimary,
color: token.colorPrimary,
background: token.colorPrimaryBg,
}
: undefined"
@click.stop="toggleFilterPopover"
>
<a-flex align="center" justify="center" :gap="6">
<FilterOutlined />
<span>{{ filterBtnLabel }}</span>
<DownOutlined />
</a-flex>
</a-button>
</a-badge>
</a-flex>
<a-flex v-else align="center" wrap="wrap" :gap="8">
<a-input-search
v-model:value="q"
allow-clear
placeholder="جستجو نام، موبایل، شهر…"
style="width: 15rem; max-width: 100%"
@search="onSearch"
>
<template #enterButton>
<a-button type="primary">
<template #icon><SearchOutlined /></template>
</a-button>
</template>
</a-input-search>
<a-badge :count="mobileFilterBadgeCount" :offset="[-2, 2]">
<a-button @click="moreFiltersOpen = true">
<template #icon><FilterOutlined /></template>
فیلترها
</a-button>
</a-badge>
<div v-if="moreFiltersOpen" data-lead-filter :style="filterPanelStyle" @click.stop>
<a-flex vertical :gap="10">
<div v-for="facet in textFacets" :key="facet.key">
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection(facet.key)"
@keydown.enter.prevent="toggleFilterSection(facet.key)"
>
<a-flex justify="space-between" align="center">
<span>{{ facet.label }}</span>
<a-typography-text :style="facetValueStyle(facet.active)">
{{ facet.value }}
</a-typography-text>
</a-flex>
</div>
<a-flex
v-if="filterSection === facet.key"
vertical
:gap="4"
:style="{ marginTop: '6px' }"
>
<a-input
v-if="facet.searchable"
v-model:value="filterQuery"
allow-clear
placeholder="جستجو"
/>
<div :style="{ maxHeight: '11.5rem', overflow: 'auto' }">
<a-button
v-for="option in visibleFacetOptions(facet.options).options"
:key="option.value || 'all'"
type="text"
block
:style="facetOptionStyle(facet.current === option.value)"
@click="pickFacet(facet.key, option.value)"
>
{{ option.label }}
</a-button>
<a-typography-text
v-if="visibleFacetOptions(facet.options).empty"
type="secondary"
>
موردی پیدا نشد
</a-typography-text>
</div>
<a-typography-text
v-if="visibleFacetOptions(facet.options).hidden"
type="secondary"
>
برای دیدن بقیه، نام را دقیق‌تر بنویسید
</a-typography-text>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdFrom')"
@keydown.enter.prevent="toggleFilterSection('createdFrom')"
>
<a-flex justify="space-between" align="center">
<span>از تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdFromFilter))">
{{ dateFacetLabel(createdFromFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdFrom'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdFromFilter"
placeholder="از تاریخ"
@update:model-value="(value: string) => { setCreatedFromFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('createdTo')"
@keydown.enter.prevent="toggleFilterSection('createdTo')"
>
<a-flex justify="space-between" align="center">
<span>تا تاریخ ثبت</span>
<a-typography-text :style="facetValueStyle(Boolean(createdToFilter))">
{{ dateFacetLabel(createdToFilter) }}
</a-typography-text>
</a-flex>
</div>
<div v-if="filterSection === 'createdTo'" :style="{ marginTop: '6px' }">
<JalaliDateInput
:model-value="createdToFilter"
placeholder="تا تاریخ"
@update:model-value="(value: string) => { setCreatedToFilter(value); filterSection = null }"
/>
</div>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('converted')"
@keydown.enter.prevent="toggleFilterSection('converted')"
>
<a-flex justify="space-between" align="center">
<span>تبدیل‌شده</span>
<a-typography-text :style="facetValueStyle(convertedFilter)">
{{ convertedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'converted'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!convertedFilter)"
@click="pickFlag('converted', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(convertedFilter)"
@click="pickFlag('converted', true)"
>
فقط تبدیل‌شده‌ها
</a-button>
</a-flex>
</div>
<div>
<div
role="button"
tabindex="0"
:style="{ cursor: 'pointer' }"
@click="toggleFilterSection('archived')"
@keydown.enter.prevent="toggleFilterSection('archived')"
>
<a-flex justify="space-between" align="center">
<span>بایگانی</span>
<a-typography-text :style="facetValueStyle(archivedFilter)">
{{ archivedFilter ? 'فقط' : 'همه' }}
</a-typography-text>
</a-flex>
</div>
<a-flex v-if="filterSection === 'archived'" vertical :style="{ marginTop: '6px' }">
<a-button
type="text"
block
:style="facetOptionStyle(!archivedFilter)"
@click="pickFlag('archived', false)"
>
همه
</a-button>
<a-button
type="text"
block
:style="facetOptionStyle(archivedFilter)"
@click="pickFlag('archived', true)"
>
فقط بایگانی‌شده‌ها
</a-button>
</a-flex>
</div>
<a-button
v-if="mobileFilterBadgeCount"
type="text"
danger
block
@click="clearMoreFilters"
>
پاک کردن فیلترها
</a-button>
</a-flex>
</div>
</div>
<a-select
:value="sort"
style="width: 10rem"
:style="isMobile ? { width: '100%' } : { width: '10rem' }"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
<a-select
:value="assigneeSelectValue"
allow-clear
show-search
option-filter-prop="label"
placeholder="مسئول"
style="width: 10rem"
:options="[
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
...users.map((u) => ({ value: u.id, label: u.fullName })),
]"
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
/>
<a-select
:value="followUpFilter || undefined"
allow-clear
placeholder="پیگیری"
style="width: 13rem"
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
/>
</a-flex>
</a-flex>
</a-card>
</a-col>
<a-drawer
v-model:open="moreFiltersOpen"
:title="isMobile ? 'فیلترها' : 'فیلترهای بیشتر'"
placement="left"
:width="isMobile ? '100%' : 380"
:destroy-on-close="false"
>
<a-form layout="vertical">
<template v-if="isMobile">
<a-form-item label="مرتب‌سازی">
<a-select
:value="sort"
style="width: 100%"
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
/>
</a-form-item>
<a-form-item label="مسئول">
<a-select
:value="assigneeSelectValue"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه مسئولان"
style="width: 100%"
:options="[
{ value: UNASSIGNED_VALUE, label: 'بدون مسئول' },
...users.map((u) => ({ value: u.id, label: u.fullName })),
]"
@update:value="(v) => setAssigneeFilter(v == null ? undefined : String(v))"
/>
</a-form-item>
<a-form-item label="پیگیری">
<a-select
:value="followUpFilter || undefined"
allow-clear
placeholder="همه پیگیری‌ها"
style="width: 100%"
:options="FOLLOW_UP_OPTIONS.filter((o) => o.value).map((o) => ({ value: o.value, label: o.label }))"
@update:value="(v) => setFollowUpFilter(v == null ? undefined : String(v))"
/>
</a-form-item>
</template>
<a-form-item label="شهر">
<a-select
:value="cityFilter || undefined"
allow-clear
show-search
placeholder="همه شهرها"
:options="cityOptions.map((c) => ({ value: c, label: c }))"
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="منطقه">
<a-select
:value="regionFilter || undefined"
allow-clear
show-search
placeholder="همه مناطق"
:options="regionOptions.map((r) => ({ value: r, label: r }))"
@update:value="(v) => setRegionFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="صنف">
<a-select
:value="tradeFilter || undefined"
allow-clear
show-search
placeholder="همه اصناف"
:options="tradeOptions.map((t) => ({ value: t, label: t }))"
@update:value="(v) => setTradeFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="منبع">
<a-select
:value="sourceFilter || undefined"
allow-clear
show-search
placeholder="همه منابع"
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
/>
</a-form-item>
<a-form-item label="از تاریخ ثبت">
<JalaliDateInput
:model-value="createdFromFilter"
placeholder="از تاریخ"
@update:model-value="setCreatedFromFilter"
/>
</a-form-item>
<a-form-item label="تا تاریخ ثبت">
<JalaliDateInput
:model-value="createdToFilter"
placeholder="تا تاریخ"
@update:model-value="setCreatedToFilter"
/>
</a-form-item>
<a-form-item>
<a-checkbox
:checked="convertedFilter"
@update:checked="setConvertedFilter"
>
فقط تبدیل‌شده‌ها
</a-checkbox>
</a-form-item>
<a-form-item>
<a-checkbox
:checked="archivedFilter"
@update:checked="setArchivedFilter"
>
فقط بایگانی‌شده‌ها
</a-checkbox>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="!mobileFilterBadgeCount" @click="clearMoreFilters">
پاک کردن
</a-button>
<a-button type="primary" @click="moreFiltersOpen = false">
بستن
</a-button>
</a-space>
</template>
</a-drawer>
<a-col v-if="selectedIds.length" :span="24" @click.stop>
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
<a-space :size="8" wrap>
+13 -2
View File
@@ -1906,7 +1906,11 @@ onBeforeUnmount(() => {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.35;
color: #0f172a;
flex-shrink: 0;
min-width: 0;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -1918,7 +1922,7 @@ onBeforeUnmount(() => {
color: #334155;
font-size: 0.82rem;
line-height: 1.45;
flex: 1;
flex: 1 1 2.9em;
min-height: 0;
overflow: hidden;
display: -webkit-box;
@@ -1953,13 +1957,15 @@ onBeforeUnmount(() => {
margin-top: 0.45rem;
border-radius: var(--radius);
overflow: hidden;
flex-shrink: 0;
flex: 0 1 72px;
min-height: 0;
max-height: 72px;
}
.card-thumb img {
display: block;
width: 100%;
height: 72px;
max-height: 100%;
object-fit: cover;
}
@@ -2176,11 +2182,16 @@ onBeforeUnmount(() => {
}
.title-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid #e2e8f0;
padding: 0.35rem 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
color: #0f172a;
outline: none;
background: transparent;
}
+41 -1
View File
@@ -97,6 +97,9 @@ const website = ref('');
const instagram = ref('');
const birthDate = ref('');
const referrer = ref('');
const nationalId = ref('');
const city = ref('');
const rasteh = ref('');
const addresses = ref<string[]>(['']);
const notes = ref('');
const tagsRaw = ref('');
@@ -283,6 +286,16 @@ function fill(p: PersonDto) {
mood.value = p.mood || 'newcomer';
groupIds.value = (p.groups ?? []).map((g) => g.id);
customFields.value = { ...(p.customFields ?? {}) };
const cf = customFields.value;
nationalId.value =
typeof cf.nationalId === 'string'
? cf.nationalId
: typeof cf.nationalId === 'number'
? String(cf.nationalId)
: '';
city.value = typeof cf.city === 'string' ? cf.city : '';
rasteh.value =
typeof cf.rasteh === 'string' || typeof cf.rasteh === 'number' ? String(cf.rasteh) : '';
loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
}
@@ -344,6 +357,18 @@ function parseTags(raw: string): string[] {
}
function payload(): CreatePersonRequest {
const fields: Record<string, unknown> = { ...customFields.value };
const nid = nationalId.value.trim();
if (nid) fields.nationalId = nid;
else delete fields.nationalId;
const cityVal = city.value.trim();
if (cityVal) fields.city = cityVal;
else delete fields.city;
const rastehRaw = rasteh.value.trim();
if (rastehRaw) {
const n = Number(rastehRaw);
fields.rasteh = Number.isFinite(n) && String(n) === rastehRaw ? n : rastehRaw;
} else delete fields.rasteh;
return {
firstName: firstName.value,
lastName: lastName.value,
@@ -363,7 +388,7 @@ function payload(): CreatePersonRequest {
companyId: companyId.value || null,
mood: mood.value,
groupIds: groupIds.value,
customFields: customFields.value,
customFields: fields,
};
}
@@ -545,6 +570,21 @@ onMounted(load);
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="کد ملی">
<a-input v-model:value="nationalId" dir="ltr" placeholder="کد ملی (ته‌حساب)" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شهر">
<a-input v-model:value="city" placeholder="شهر" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="رسته">
<a-input v-model:value="rasteh" dir="ltr" placeholder="رسته حساب ته‌حساب" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="right">آدرس‌ها</a-divider>
+36
View File
@@ -183,6 +183,18 @@ const externalCode = computed(() => {
}
return '';
});
function customFieldText(...keys: string[]) {
const fields = person.value?.customFields ?? {};
for (const key of keys) {
const raw = fields[key];
if (typeof raw === 'string' && raw.trim()) return raw.trim();
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
}
return '';
}
const nationalIdLabel = computed(() => customFieldText('nationalId', 'CodeMelli', 'codeMeli'));
const cityLabel = computed(() => customFieldText('city', 'City'));
const rastehLabel = computed(() => customFieldText('rasteh', 'Rasteh'));
const SOURCE_LABELS: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
@@ -444,6 +456,9 @@ const infoEmpty = computed(() => {
!p.instagram &&
!p.birthDate &&
!p.referrer &&
!nationalIdLabel.value &&
!cityLabel.value &&
!rastehLabel.value &&
!(p.addresses?.length) &&
!p.company &&
!externalCode.value &&
@@ -794,6 +809,18 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>معرف</dt>
<dd>{{ person.referrer }}</dd>
</div>
<div v-if="nationalIdLabel" class="fact">
<dt>کد ملی</dt>
<dd dir="ltr">{{ toFaDigits(nationalIdLabel) }}</dd>
</div>
<div v-if="cityLabel" class="fact">
<dt>شهر</dt>
<dd>{{ cityLabel }}</dd>
</div>
<div v-if="rastehLabel" class="fact">
<dt>رسته</dt>
<dd dir="ltr">{{ toFaDigits(rastehLabel) }}</dd>
</div>
<div v-if="person.website" class="fact">
<dt>وب‌سایت</dt>
<dd dir="ltr">{{ person.website }}</dd>
@@ -1119,6 +1146,15 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div><dt>وب‌سایت</dt><dd>{{ person.website || '—' }}</dd></div>
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
<div>
<dt>کد ملی</dt>
<dd dir="ltr">{{ nationalIdLabel ? toFaDigits(nationalIdLabel) : '—' }}</dd>
</div>
<div><dt>شهر</dt><dd>{{ cityLabel || '—' }}</dd></div>
<div>
<dt>رسته</dt>
<dd dir="ltr">{{ rastehLabel ? toFaDigits(rastehLabel) : '—' }}</dd>
</div>
<div class="wide">
<dt>آدرس‌ها</dt>
<dd>
+77 -46
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
@@ -108,6 +108,7 @@ const overlayAccent = ref<PipelineStage['color']>('green');
const overlayPos = ref({ x: 0, y: 0 });
const menuOpenId = ref<string | null>(null);
const moving = ref(false);
let dragSessionId: string | null = null;
const pipelineOptions = computed(() =>
pipelines.value.map((p) => ({
@@ -164,9 +165,7 @@ function applyDealsSavedFilters(filters: Record<string, unknown>) {
}
function formatDealDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliNumeric(d);
return formatRelativeDayLabel(iso);
}
function findAccent(dealId: string): PipelineStage['color'] {
@@ -177,7 +176,9 @@ function findAccent(dealId: string): PipelineStage['color'] {
}
async function loadPipelines() {
const { data } = await api.get<PipelineDto[]>('/pipelines');
const { data } = await api.get<PipelineDto[]>('/pipelines', {
params: { forLens: '1' },
});
pipelines.value = data;
}
@@ -186,16 +187,23 @@ async function load() {
error.value = '';
try {
await loadPipelines();
const pipelineId =
typeof route.query.pipelineId === 'string'
? route.query.pipelineId
: undefined;
const requested =
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : '';
const pipelineId = pipelines.value.some((item) => item.id === requested)
? requested
: pipelines.value[0]?.id;
if (!pipelineId) {
boardPipelineId.value = '';
stages.value = [];
if (requested) await router.replace({ name: 'pipeline', query: {} });
return;
}
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pipelineId ? { pipelineId } : undefined,
params: { pipelineId },
});
boardPipelineId.value = data.pipeline.id;
stages.value = mapBoardToStages(data, formatDealDate);
if (!pipelineId || pipelineId !== data.pipeline.id) {
if (requested !== data.pipeline.id) {
await router.replace({
name: 'pipeline',
query: { pipelineId: data.pipeline.id },
@@ -235,13 +243,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
draggingColumnId.value = null;
if (moving.value) moving.value = false;
dragSessionId = deal.id;
event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) {
@@ -250,12 +254,29 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
// DOM را بعد از dragstart عوض کن تا کروم درگ را لغو نکند
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -295,37 +316,45 @@ async function applyUpdateDealStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw =
event.dataTransfer?.getData('text/plain') ||
draggingColumnId.value ||
draggingId.value ||
'';
const transfer = event.dataTransfer?.getData('text/plain') || '';
const draggedCardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value;
dropStageId.value = null;
dragSessionId = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
draggingColumnId.value = null;
// کارت بر payload ستون اولویت دارد
const dealId =
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (dealId) {
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
// اگر جابه‌جایی قبلی هنوز در جریان است، این یکی را صف نکن — فقط رد نکن بی‌صدا با early return قدیمی
if (moving.value) {
moving.value = false;
}
await applyUpdateDealStage(dealId, stageId, index);
return;
}
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
}
function apiMessage(e: unknown, fallback: string) {
@@ -342,6 +371,8 @@ function onAddDeal(stageId: string) {
}
function onColumnDragStart(stageId: string) {
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = stageId;
}
+47 -29
View File
@@ -56,6 +56,7 @@ const busy = ref(false);
const importMsg = ref('');
const draggingId = ref<string | null>(null);
let dragSessionId: string | null = null;
const draggingColumnId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
@@ -177,13 +178,9 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
draggingColumnId.value = null;
if (moving.value) moving.value = false;
dragSessionId = deal.id;
event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) {
@@ -192,12 +189,28 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -237,30 +250,33 @@ async function applyUpdateCardStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw =
event.dataTransfer?.getData('text/plain') ||
draggingColumnId.value ||
draggingId.value ||
'';
const transfer = event.dataTransfer?.getData('text/plain') || '';
const draggedCardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value;
dropStageId.value = null;
dragSessionId = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
draggingColumnId.value = null;
const cardId =
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (cardId) {
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
if (moving.value) moving.value = false;
await applyUpdateCardStage(cardId, stageId, index);
return;
}
const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
return;
}
const cardId = raw;
if (!cardId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
await applyUpdateCardStage(cardId, stageId, index);
}
function onAddCard(stageId: string) {
@@ -268,6 +284,8 @@ function onAddCard(stageId: string) {
}
function onColumnDragStart(stageId: string) {
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = stageId;
}
+120 -12
View File
@@ -64,6 +64,9 @@ type SyncResult = {
alreadyLinked?: boolean;
personId?: string | null;
personName?: string | null;
duplicateOfCode?: number | null;
duplicatePersonId?: string | null;
duplicatePersonName?: string | null;
};
const syncResults = ref<SyncResult[]>([]);
/** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */
@@ -145,9 +148,96 @@ async function saveAutoSync() {
autoSyncSaving.value = false;
}
}
/** همهٔ مشتریان ته‌حساب را می‌خواند: تازه‌ها وارد، لینک‌شده‌ها پروفایل‌شان به‌روز می‌شود */
async function syncCustomersNow() {
if (!sync.connectionId) {
error.value = 'اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = null;
importOverlay.message =
'در حال همگام‌سازی همهٔ مشتریان… نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه به‌روز می‌شود.';
error.value = '';
ok.value = '';
try {
const { data } = await api.post<{
imported: number;
updated: number;
duplicate: number;
failed: number;
summary: string;
error: string | null;
}>('/integrations/tahesab/sync/customers-now', {
connectionId: sync.connectionId,
});
ok.value = data.summary;
if (data.error) error.value = data.error;
await load();
} catch (e) {
error.value = apiErr(e, 'همگام‌سازی مشتریان ناموفق بود');
} finally {
syncBusy.value = false;
importOverlay.open = false;
}
}
/** همان قاعدهٔ سرور: موبایل ایران 09… است، بقیهٔ شماره‌ها فقط رقم‌اند. */
function customerPhoneKey(value?: string | null): string {
const fa = '۰۱۲۳۴۵۶۷۸۹';
const ar = '٠١٢٣٤٥٦٧٨٩';
let digits = String(value ?? '')
.replace(/[۰-۹٠-٩]/g, (ch) => {
const faIndex = fa.indexOf(ch);
return String(faIndex >= 0 ? faIndex : ar.indexOf(ch));
})
.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0098') && digits.length >= 14) digits = digits.slice(4);
else if (digits.startsWith('98') && digits.length >= 12) digits = digits.slice(2);
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
return digits;
}
const linkedPhoneOwners = computed(() => {
const owners = new Map<string, { code: number; personId: string | null; personName: string | null }>();
for (const row of syncResults.value) {
if (!row.alreadyLinked || row.moshtariCode == null) continue;
const key = customerPhoneKey(row.tel);
if (key && !owners.has(key)) {
owners.set(key, {
code: row.moshtariCode,
personId: row.personId ?? null,
personName: row.personName ?? null,
});
}
}
return owners;
});
function duplicateOwner(row: SyncResult) {
if (row.alreadyLinked || row.moshtariCode == null) return null;
if (row.duplicateOfCode != null) {
return {
code: row.duplicateOfCode,
personId: row.duplicatePersonId ?? null,
personName: row.duplicatePersonName ?? null,
};
}
const key = customerPhoneKey(row.tel);
if (!key) return null;
const owner = linkedPhoneOwners.value.get(key);
if (!owner || owner.code === row.moshtariCode) return null;
return owner;
}
const duplicateCustomerCount = computed(
() => syncResults.value.filter((row) => duplicateOwner(row)).length,
);
const syncVisibleResults = computed(() =>
syncOnlyNew.value
? syncResults.value.filter((r) => !r.alreadyLinked)
? syncResults.value.filter((row) => !row.alreadyLinked && !duplicateOwner(row))
: syncResults.value,
);
const syncTableTotalPages = computed(() =>
@@ -184,7 +274,7 @@ const productCategoryLabel = ref('کارساخته');
function pendingImportCodes() {
return syncResults.value
.filter((r) => r.moshtariCode != null && !r.alreadyLinked)
.filter((r) => r.moshtariCode != null && !r.alreadyLinked && !duplicateOwner(r))
.map((r) => r.moshtariCode as number);
}
@@ -458,10 +548,12 @@ async function searchTahesab(mode: 'range' | 'filter') {
ok.value = 'نتیجه‌ای یافت نشد.';
} else {
const linked = syncResults.value.filter((r) => r.alreadyLinked).length;
const pending = syncResults.value.length - linked;
const duplicate = syncResults.value.filter((r) => duplicateOwner(r)).length;
const pending = syncResults.value.length - linked - duplicate;
ok.value =
`${toFaDigits(syncResults.value.length)} مشتری پیدا شد` +
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`;
` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده` +
(duplicate ? ` · ${toFaDigits(duplicate)} شماره تکراری` : '');
}
} catch (e) {
error.value = apiErr(e, 'جستجو ناموفق');
@@ -951,9 +1043,9 @@ onMounted(load);
<div class="sync-sub-section">
<h4 class="sync-subhead">بروزرسانی خودکار</h4>
<p class="sync-sub-desc">
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران،
مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد
همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی.
«همگام‌سازی الان» همهٔ مشتریان ته‌حساب را می‌خواند: تازه‌ها را وارد می‌کند و برای مشتری‌های از قبل
لینک‌شده نام، موبایل، کد ملی، آدرس، شهر، رسته و گروه را از ته‌حساب تازه می‌کند. زمان‌بندی زیر
همان کار را در ساعت‌های انتخابی (به وقت تهران) تکرار می‌کند. خاموش یعنی فقط ورود دستی.
</p>
<UiField label="تعداد در روز">
<UiSelect
@@ -986,8 +1078,16 @@ onMounted(load);
</div>
<div class="sync-buttons">
<UiButton
:loading="syncBusy"
:disabled="!sync.connectionId || syncBusy || autoSyncSaving"
@click="syncCustomersNow"
>
همگام‌سازی الان (وارد + به‌روز کردن قبلی‌ها)
</UiButton>
<UiButton
variant="secondary"
:loading="autoSyncSaving"
:disabled="!sync.connectionId"
:disabled="!sync.connectionId || syncBusy"
@click="saveAutoSync"
>
ذخیره زمان‌بندی
@@ -1100,6 +1200,9 @@ onMounted(load);
<h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
</h4>
<p v-if="duplicateCustomerCount" class="sync-sub-desc">
{{ toFaDigits(duplicateCustomerCount) }} مشتری شمارهٔ تکراری دارند و جداگانه وارد نمی‌شوند؛ همان شخص با کد دیگری از قبل لینک است.
</p>
<div class="tc-row">
<TableColumnsMenu table-key="tahesab.customers" align="end" />
</div>
@@ -1120,7 +1223,9 @@ onMounted(load);
</thead>
<tbody>
<tr v-if="!syncPagedResults.length">
<td colspan="5" class="sync-empty-row">همهٔ مشتریان این نتایج قبلاً وارد شده‌اند.</td>
<td colspan="5" class="sync-empty-row">
مشتری تازه‌ای برای ورود نیست. شماره‌های تکراری از قبل با کد دیگری لینک شده‌اند.
</td>
</tr>
<tr v-for="r in syncPagedResults" :key="String(r.moshtariCode)">
<td class="font-mono">{{ r.moshtariCode }}</td>
@@ -1130,11 +1235,14 @@ onMounted(load);
<UiBadge v-if="r.alreadyLinked" variant="success">
لینک شده{{ r.personName ? ` (${r.personName})` : '' }}
</UiBadge>
<UiBadge v-else-if="duplicateOwner(r)" variant="warning">
شماره تکراری، کد {{ toFaDigits(duplicateOwner(r)!.code) }}
</UiBadge>
<UiBadge v-else variant="neutral">آماده ورود</UiBadge>
</td>
<td>
<UiButton
v-if="!r.alreadyLinked"
v-if="!r.alreadyLinked && !duplicateOwner(r)"
size="sm"
:disabled="!r.moshtariCode || syncBusy"
@click="importOne(r.moshtariCode!)"
@@ -1142,8 +1250,8 @@ onMounted(load);
وارد کردن
</UiButton>
<RouterLink
v-else-if="r.personId"
:to="{ name: 'person-detail', params: { id: r.personId } }"
v-else-if="r.personId || duplicateOwner(r)?.personId"
:to="{ name: 'person-detail', params: { id: (r.personId || duplicateOwner(r)?.personId)! } }"
>
<UiButton variant="ghost" size="sm">
مشاهده شخص

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